Compare commits

...

835 Commits
1.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
Eevee (Evelyn Woods)
3752902663 Move gates to the canopy layer 2021-05-25 17:42:13 -06:00
Eevee (Evelyn Woods)
753a375e89 Comment out this incredibly annoying C2M warning 2021-05-25 17:41:38 -06:00
Eevee (Evelyn Woods)
7c0335a24d Update tileset: minor touchups, lighter popwalls, killer indicator, some experiments 2021-05-25 17:41:23 -06:00
Eevee (Evelyn Woods)
6de69604d9 Extend CCL support with a chunk for storing the author's name 2021-05-25 17:27:41 -06:00
Eevee (Evelyn Woods)
6bce30545d Update Lexy's Lessons; much smoother progression, lotta bugs fixed 2021-05-25 17:14:43 -06:00
Eevee (Evelyn Woods)
04b284a267 Fix the hint border to match the game area background again 2021-05-22 20:25:37 -06:00
Eevee (Evelyn Woods)
9e1adc768b Coax the small-screen media queries into working in Chrome 2021-05-22 02:13:04 -06:00
Eevee (Evelyn Woods)
952ec10cb5 Remove errant manifest mention 2021-05-21 21:40:34 -06:00
Eevee (Evelyn Woods)
41e5b5f9b8 Rework mobile layout to be more compact, et al.
- On small screens, the top two headers (with the pack + level names)
  are now removed; instead the pack and level name are shown when
  starting each level, and the buttons from those headers are moved into
  a pause menu.

- The options, compat, and level browser dialogs were all reworked to
  fit better on narrow screens.

- The level overlay has a more consistent layout and tries harder to not
  draw in the middle, where the player generally is (except that the
  mobile pause menu goes there, but oh well).

- The score tally at the end of a level is now less of a small table and
  more of...  more numbers, I guess?

- Links to the music source and author now open in a new window to
  reduce risk of accidentally clicking them and losing your progress.

- A few obituaries were shortened, and several more were added.

- The game ending screen is now accessible on a touchscreen (oops).

- The pause and rewind buttons visually indicate when you're in that
  mode, suggesting you can hit them again to switch to normal play.

- Touch controls are now relative to the player and only apply within
  the game viewport.

- Disabled buttons look a bit less janky.

Still some work to do on this, but it's a pretty solid start.
2021-05-21 21:10:44 -06:00
Eevee (Evelyn Woods)
8b03d09c78 Add a slight 3D effect to buttons 2021-05-17 19:15:54 -06:00
Eevee (Evelyn Woods)
ae8b42e0c9 Merge Lexy/Lynx loops; add compat for separated teleport phase 2021-05-17 19:12:04 -06:00
Eevee (Evelyn Woods)
a6aaaa7266 Shift the color scheme back towards blue somewhat 2021-05-17 18:37:37 -06:00
Eevee (Evelyn Woods)
feaf09e4e2 Hide editor facing arrows for lone blocks; add support for killer indicator 2021-05-16 18:00:14 -06:00
Eevee (Evelyn Woods)
9e45710189 Shift the web color palette down a bit to cyan 2021-05-16 17:56:07 -06:00
Eevee (Evelyn Woods)
53ed2f0948 Add support for rotating or flipping a level or selection 2021-05-16 17:52:31 -06:00
Eevee (Evelyn Woods)
7ed3d38489 Implement monsters not hurting you at decision time in Lynx 2021-05-11 18:31:16 -06:00
Eevee (Evelyn Woods)
db02c19a0d "Fix" the Lynx TWS of Southpole by extending the "out of input" threshold a bit 2021-05-11 18:16:34 -06:00
Eevee (Evelyn Woods)
e8cb95a60b Fix double-ejecting from traps in Lynx 2021-05-11 17:39:19 -06:00
Eevee (Evelyn Woods)
257e9db64b Preserve the CCL auto-fixes for Lynx and MS modes 2021-05-11 17:39:07 -06:00
Eevee (Evelyn Woods)
3c00e0ba36 Fix rolling a second RFF direction under Lynx 2021-05-11 17:09:25 -06:00
Eevee (Evelyn Woods)
c9a2897bc2 Consolidate Lynx-style player death with CC2 2021-05-11 16:40:00 -06:00
Eevee (Evelyn Woods)
fc1f85dac9 Fix Lynx force floor compat switch; ensure monsters can't turn in traps 2021-05-11 16:31:28 -06:00
Eevee (Evelyn Woods)
9369b2b167 Partially restore Lynx force floor behavior 2021-05-10 20:46:19 -06:00
Eevee (Evelyn Woods)
ca42dbcf59 Fix bulk tester's parsing of single level numbers 2021-05-10 20:36:55 -06:00
Eevee (Evelyn Woods)
08c86c6129 Refactor sliding handling
Eliminates a number of annoying little hacks by getting rid of
`slide_mode` and instead trusting the terrain, live, like CC2 seems to
do (and Lynx definitely does).
2021-05-10 20:23:02 -06:00
Eevee (Evelyn Woods)
b375f431af Bestow the bulk tester with arguments and get this local-to-me hack stuff outta here 2021-05-08 19:07:46 -06:00
Eevee (Evelyn Woods)
af66a53b2b Fix ghosts switching railroad tracks 2021-05-08 17:51:56 -06:00
Eevee (Evelyn Woods)
dfc8798ff6 Fix turtle splashes erasing dropped dynamite 2021-05-08 17:51:46 -06:00
Eevee (Evelyn Woods)
172a8e8a6b Stick the buggy CC2 green teleport behavior behind a compat flag 2021-05-08 17:31:47 -06:00
Eevee (Evelyn Woods)
eebe8b9581 Emulate an obscure CC2 bug when selecting a green teleport destination 2021-05-08 17:13:48 -06:00
Eevee (Evelyn Woods)
8efa3a572a Partially consolidate slide overriding on force floors vs teleports 2021-05-07 23:44:05 -06:00
Eevee (Evelyn Woods)
e45a580d1a Restore the template's facing after a failed wired clone
Also remove a hack for an edge case that's been fixed with the recent
changes to death handling.
2021-05-07 22:40:30 -06:00
Eevee (Evelyn Woods)
2b488b2d89 Disable lilypad/popwall effects when activating dynamite 2021-05-07 22:40:21 -06:00
Eevee (Evelyn Woods)
642c977df3 Prevent monsters killing you after you teleported 2021-05-07 22:39:54 -06:00
Eevee (Evelyn Woods)
b7e352a4a3 Switch to Lynx's delayed green button effect 2021-05-07 22:39:31 -06:00
Eevee (Evelyn Woods)
87d7952960 Fix lilypad splash duration being slightly longer than expected 2021-05-07 18:54:56 -06:00
Eevee (Evelyn Woods)
99dec75731 Split the editor up 2021-05-07 17:57:25 -06:00
Eevee (Evelyn Woods)
9883dcf4ef Bring death and spring mining more into line with CC2
- Players and monsters do, in fact, block each other.  The helmet only
  prevents death.

- Death happens during collision check, which is the entire reason items
  don't save you: you're collided with first!  This allows removing
  several special cases.

- Spring mining is prevented almost incidentally, by virtue of collision
  being checked both at decision time and movement time.  It /can/
  happen to actors other than the player, but seemingly not blocks.

- Some monsters, whose movement is essentially forced anyway, skip the
  decision time collision check.  This includes doppelgangers, which is
  why they always spring mine.
2021-05-07 17:51:11 -06:00
Eevee (Evelyn Woods)
24a55d7c88 Move the hairy Cell collision methods into Level 2021-05-06 12:44:29 -06:00
Eevee (Evelyn Woods)
49b691adde Allow yellow tanks into fire and rovers into more tiles 2021-05-03 21:38:42 -06:00
Eevee (Evelyn Woods)
7c498f195e Improve editor rendering of connections, a bit 2021-04-30 13:22:50 -06:00
Eevee (Evelyn Woods)
f7ee18a28c Add trap/cloner connection export to CCL 2021-04-28 22:15:21 -06:00
Eevee (Evelyn Woods)
eff62a9765 Merge trap/cloner connections; round-trip them through C2M; stub out connect tool 2021-04-28 22:05:01 -06:00
Eevee (Evelyn Woods)
7f90ee5f7d Give the blue and green walls better names 2021-04-28 19:46:39 -06:00
Eevee (Evelyn Woods)
81b305b2f6 Add support for exporting hints to CCL; remove level hints 2021-04-28 19:46:21 -06:00
Eevee (Evelyn Woods)
58cc6ff61e Consolidate editor export buttons into a menu 2021-04-28 19:25:49 -06:00
Eevee (Evelyn Woods)
c1bf88d3dd Add rudimentary support for CCL export 2021-04-28 18:44:01 -06:00
Eevee (Evelyn Woods)
f48cef5250 Fix the hint editor to make an undo entry 2021-04-26 16:02:24 -06:00
Eevee (Evelyn Woods)
f2366be039 Move diamond iteration to algorithms so the editor can (eventually) use it 2021-04-26 15:57:13 -06:00
Eevee (Evelyn Woods)
4077bd0de3 Center the canvas independently along each axis 2021-04-26 10:24:01 -06:00
Eevee (Evelyn Woods)
fd3e657387 Draw the editor's viewport shadow on top of the canvas 2021-04-24 14:32:26 -06:00
Eevee (Evelyn Woods)
167360f596 Add a zoom control 2021-04-24 11:43:28 -06:00
Eevee (Evelyn Woods)
e3a128df60 Add the cursor position to the editor's fledgling status bar 2021-04-23 14:37:20 -06:00
Eevee (Evelyn Woods)
ea9cc5ef07 Make editor zoom preserve the cursor point; allow wider panning 2021-04-23 14:27:52 -06:00
Eevee (Evelyn Woods)
a7553457ad Add mousewheel zooming to the editor 2021-04-23 13:28:20 -06:00
Eevee (Evelyn Woods)
9e090f967d Make erasing thin walls undoable; fix ctrl key sometimes not being recognized 2021-03-14 16:57:41 -06:00
Eevee (Evelyn Woods)
29fb8791e5 Fix using shift with the adjust tool 2021-03-14 15:16:30 -06:00
Eevee (Evelyn Woods)
2ab983ec0a Swap ctrl with right click for most editor tools; add bg tile
Also merges hover with mouse operations proper, which simplifies some
things.
2021-03-14 15:08:19 -06:00
Eevee (Evelyn Woods)
4399c9c75a Fix kb shortcuts no longer working in the editor after editing level properties 2021-03-13 20:16:37 -07:00
Eevee (Evelyn Woods)
ba7e715222 Bind U to undo 2021-03-13 19:02:11 -07:00
Eevee (Evelyn Woods)
854ad03523 Spruce up player UI slightly; add space for level rules; add fullscreen button for mobile 2021-03-13 18:53:27 -07:00
Eevee (Evelyn Woods)
d251955684 Make red teleporters count as wired if they neighbor a logic gate 2021-03-13 18:05:29 -07:00
Eevee (Evelyn Woods)
5384561413 Add a new tile, the one-way thin wall 2021-03-13 18:05:13 -07:00
Eevee (Evelyn Woods)
fa06eb8d7a Allow editing level comments; touch up level props dialog (fixes #47) 2021-03-13 18:02:49 -07:00
Eevee (Evelyn Woods)
3b257df8d3 Add sound effects for time items and early exit 2021-03-13 17:55:10 -07:00
Eevee (Evelyn Woods)
0b957cfeb1 Preserve wires when drawing a wireable tile in the editor; light switches don't propagate 2021-03-10 23:20:00 -07:00
Eevee (Evelyn Woods)
3020e3b038 Count chips in C2Ms on level start, not on parse (so editing updates the chip count) 2021-03-10 22:19:47 -07:00
Eevee (Evelyn Woods)
56611958f7 Draw actor facing directions in the editor (fixes #38) 2021-03-10 20:47:07 -07:00
Eevee (Evelyn Woods)
28a26cdc14 Add modification tracking to the editor and wire it into undo/redo 2021-03-10 20:28:26 -07:00
Eevee (Evelyn Woods)
9c5b241cae Add editor keyboard shortcuts for select all and deselect all 2021-03-10 19:27:49 -07:00
Eevee (Evelyn Woods)
58deed916c Add editor keyboard shortcuts for undo and selecting (some) tools 2021-03-10 19:19:08 -07:00
Eevee (Evelyn Woods)
a0f282fb8e Add mouse preview to the editor; implement the fill tool 2021-03-10 19:08:19 -07:00
Eevee (Evelyn Woods)
fa85d06271 Fix the stack trace in caught syntax errors in Chrome 2021-03-10 19:07:03 -07:00
Eevee (Evelyn Woods)
f1681d18c2 Fix removing the error handler after initial load 2021-03-10 17:06:23 -07:00
Eevee (Evelyn Woods)
14d9c8ade9 Intercept and report syntax errors 2021-03-10 16:37:16 -07:00
Eevee (Evelyn Woods)
63609ba77e Fix a few more Lynx compat issues 2021-03-08 23:53:52 -07:00
Eevee (Evelyn Woods)
2dcd73d44a Fix loading of initial RFF direction from Tile World solutions 2021-03-08 21:33:03 -07:00
Eevee (Evelyn Woods)
dd10236b22 Implement "hide logic", and actually save options (fixes #52) 2021-03-08 21:04:23 -07:00
Eevee (Evelyn Woods)
028fc016b0 Add an ankh description in the editor 2021-03-08 20:16:14 -07:00
Eevee (Evelyn Woods)
48806a3dfd Fix canopies being indestructible 2021-03-08 20:15:55 -07:00
Eevee (Evelyn Woods)
e8f82d885f Add a compat flag for the cloned bowling ball change 2021-03-08 20:06:02 -07:00
Eevee (Evelyn Woods)
1e5160b40d Use kill_actor when appropriate; fix some bowling ball behavior 2021-03-08 20:02:23 -07:00
Eevee (Evelyn Woods)
2cf6afa590 Slow down force floors and animated chips slightly 2021-03-08 18:53:36 -07:00
Eevee (Evelyn Woods)
cf2f399371 Fix some subtle issues caused by 60 FPS updating 2021-03-08 18:53:11 -07:00
Eevee (Evelyn Woods)
a36862e65b Revamp the halo into an ankh 2021-03-07 19:36:48 -07:00
Eevee (Evelyn Woods)
c6c904ca68 Abandon the .mjs naming scheme
Browsers refuse to load a JS module if the mimetype is wrong, and even
Python's http.server doesn't recognize this extension yet.
2021-03-07 18:06:42 -07:00
Eevee (Evelyn Woods)
db34ca72f9 Make turntables slide actors out of them and toggle on edge flip
Also fix the default display of unpowered tiles while I'm in here.
2021-03-07 18:05:55 -07:00
Eevee (Evelyn Woods)
59d26e6a00 Add a vfx for falling into a hole 2021-03-07 00:56:11 -07:00
Eevee (Evelyn Woods)
662787c287 Add palette support for sokoban tiles 2021-03-07 00:46:01 -07:00
Eevee (Evelyn Woods)
e69ac492c7 Remove the item lock tile 2021-03-07 00:43:56 -07:00
Eevee (Evelyn Woods)
e5fd2b67da Make turntables eject their contents 2021-03-07 00:42:19 -07:00
Eevee (Evelyn Woods)
0be59c21eb Make the glass block shatter on spikes and work with the transmogrifier 2021-03-07 00:23:40 -07:00
Eevee (Evelyn Woods)
ada36e8d61 Implement sokoban blocks 2021-03-07 00:07:18 -07:00
Eevee (Evelyn Woods)
bf8b55a9c9 Clear swap_player1 more aggressively 2021-03-06 22:52:03 -07:00
Eevee (Evelyn Woods)
f8e4b5e707 Fix rendering jump when reaching the exit; fix debug teleport drawing 2021-03-06 22:33:58 -07:00
Eevee (Evelyn Woods)
4a5f0e36c6 Run Steam mode at 60 FPS; fix turn-based mode, again (fixes #17, fixes #54) 2021-03-06 22:20:46 -07:00
Eevee (Evelyn Woods)
ed7c7461b6 Use webkit prefix on mask-image (fixes #57) 2021-03-06 20:19:35 -07:00
Eevee (Evelyn Woods)
fbe10e90a2 Fix p >= 1 in steam-strict, again 2021-03-06 19:01:45 -07:00
Eevee (Evelyn Woods)
26c66d6857 Make animations explicit, and fix almost every rendering problem
- `Tileset.animation_slowdown` is gone!
- Actors are now free to animate every move or every two moves, and have
  been configured to do so appropriately.  This fixes the appearance of
  blobs, most noticeably.  (Also fixes #36.)
- Actors that are always animated in CC2 are now always animated in LL.
- Lilypads/turtles now bob randomly.  More randomly than CC2, even.
- Players no longer appear to be swimming when stepping off of lilypads.
- Invisible walls no longer temporarily reveal themselves when you have
  the x-ray glasses (secret eye).
- There's a new option for using the CC2 animation timing, though god
  knows why you would want to.
2021-03-06 18:41:49 -07:00
Eevee (Evelyn Woods)
8533eac5db Fix the name of the compat mode on page load 2021-03-06 14:38:27 -07:00
Eevee (Evelyn Woods)
55a3daa649 Quiet distant sound effects much more aggressively 2021-03-06 14:34:03 -07:00
Eevee (Evelyn Woods)
1f2a58d21c Add support for headless bulk testing 2021-03-06 12:39:16 -07:00
Eevee (Evelyn Woods)
dac868edbf Try out different colors for the different game stats 2021-03-05 14:04:09 -07:00
Eevee (Evelyn Woods)
4c9afe5a9f Fix some typos in the new LL tile layout 2021-03-05 13:55:07 -07:00
Eevee (Evelyn Woods)
be275d380d Add a bunch of Lynx compat options 2021-03-05 13:54:38 -07:00
Eevee (Evelyn Woods)
a750a569ab Fix my fix for ?., which broke a couple levels 2021-03-04 14:58:10 -07:00
Eevee (Evelyn Woods)
fae19ab37b Allow monsters to pass through chips in order to kill a player 2021-03-04 13:50:19 -07:00
Eevee (Evelyn Woods)
b7e05f2eb9 Fix backwards trap tiles 2021-03-04 13:38:40 -07:00
Eevee (Evelyn Woods)
459e71e632 Allow using a custom LL tileset 2021-03-02 21:47:27 -07:00
Eevee (Evelyn Woods)
821bc4201f Unhardcode the placement of counter numbers on the tilesheet 2021-03-02 21:46:57 -07:00
Eevee (Evelyn Woods)
4cb2afcc74 Avoid the very new ?. syntax (fixes #56) 2021-03-02 21:38:31 -07:00
Eevee (Evelyn Woods)
5443514583 Revert handling of negative animation time, which should never happen 2021-03-02 21:34:28 -07:00
Eevee (Evelyn Woods)
e3443b73d8 Correctly erase the background when loading a custom CC2 tileset 2021-03-02 21:33:42 -07:00
Eevee (Evelyn Woods)
751b6b92c3 Rearrange the Lexy tileset 2021-03-02 21:33:42 -07:00
Eevee
7dbaeec606
Merge pull request #64 from Patashu/master
fix some custom tiles issues
2021-03-02 21:33:22 -07:00
Timothy.Stiles
e4ce9d0bcc glass block-with-item can't move onto a tile with an item in it 2021-03-03 14:15:17 +11:00
Timothy.Stiles
6971eb4d54 dynamite vs electrified floor, holes and cracked floor (60 partial fix) 2021-03-03 14:10:44 +11:00
Timothy.Stiles
ee718323cd glass block blown up by dynamite/halo drops its item (fixes #62) 2021-03-03 13:51:25 +11:00
Timothy.Stiles
094e94a69c for fun, let glass blocks pick up chips; let's see what happens? 2021-03-03 13:33:13 +11:00
Timothy.Stiles
7c35782079 fix glass blocks vs mines and pseudo-items (fixes #63, 59) 2021-03-03 13:25:46 +11:00
Timothy.Stiles
4037cdf86b don't crash in _draw_standard if p < 0 (fixes #58) 2021-03-03 13:04:55 +11:00
Eevee (Evelyn Woods)
406243d490 Fix green teleporter selection behavior 2021-03-01 21:09:10 -07:00
Eevee (Evelyn Woods)
d5b9a2a307 Change flame jet activation to be actor-based
This fixes CC2LP1's Waterfall, and allows me to remove `on_tic`.
2021-03-01 20:21:59 -07:00
Eevee (Evelyn Woods)
5fcce3f453 Delay black buttons by a frame, like switches (fixes #48) 2021-03-01 18:46:27 -07:00
Eevee (Evelyn Woods)
0119f45d54 Name compat flags more consistently; add a couple; flip sliding blue tank behavior 2021-03-01 18:04:23 -07:00
Eevee (Evelyn Woods)
3359c21387 Consolidate some repeated tile properties 2021-03-01 14:28:56 -07:00
Eevee (Evelyn Woods)
a294338080 Implement some more properties of lit dynamite 2021-03-01 14:06:21 -07:00
Eevee (Evelyn Woods)
0d59ffef85 Implement item priority (fixes #37 #50 #51)
Also expand the blue key MS compat flag to make keys never block
monsters; allow rovers to pass through score flags; and allow
doppelgängers to pause the clock.
2021-03-01 13:13:01 -07:00
Eevee (Evelyn Woods)
20e67b491e Finish the CC2 tileset
Most prominently: the red/blue teeth now have slightly different
designs, as do the green/purple toggle walls, which should make them
more colorblind-friendly.

Also some other minor stuff.
2021-02-28 19:21:27 -07:00
Eevee (Evelyn Woods)
fbd256c876 Fix playing the wall bump sound repeatedly in steam mode (fixes #49) 2021-02-28 18:52:51 -07:00
Eevee (Evelyn Woods)
3bc6aa9c7d Fix a crash with slide_ignores when using the CC1 inventory 2021-02-28 18:36:59 -07:00
Eevee (Evelyn Woods)
0ca0467192 Perform bonking for normal force floors as well, which fixes several subtle block pushing issues 2021-02-28 18:31:08 -07:00
Eevee (Evelyn Woods)
f1ba1815f7 Replace the player's stat labels with icons 2021-02-26 18:37:21 -07:00
Eevee
e8a6ae4a27
Merge pull request #41 from Patashu/custom-tiles
Custom tiles: Terraformer and Global Cycler
2021-02-25 20:45:22 -07:00
Timothy Stiles
b74ce300e5 fix ice block/fireball interaction 2021-02-21 10:23:38 +11:00
Timothy Stiles
ac008e9564 fix a speed boots regression 2021-02-21 10:13:30 +11:00
Timothy Stiles
4ee724030c Implement Glass block
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.
2021-02-19 18:21:29 +11:00
Timothy Stiles
e30fd1e5fd remove some other terraformer remnants 2021-02-19 09:25:53 +11:00
Timothy Stiles
c463b83ce1 oops one more fire_sticks mention 2021-02-19 08:54:11 +11:00
Timothy Stiles
c7af08b694 nix global cycler, terraformer and dormant lava 2021-02-19 08:48:21 +11:00
Eevee (Evelyn Woods)
3946880156 Add slide_mode to debug actor tooltip 2021-02-18 11:37:20 -07:00
Eevee (Evelyn Woods)
0642915c16 Add trademark disclaimer to LL itself 2021-02-18 11:37:02 -07:00
Eevee (Evelyn Woods)
bdbab78840 Add trademark disclaimer to README 2021-02-18 11:25:39 -07:00
Timothy Stiles
3ea7a045da revamp electric floor, perf improvement for circuitry recalculation
Now reads:
Conducts power (like a 4-way wire). While powered, destroys anything not wearing lightning boots (except dirt blocks)
And when you step on it with lightning boots it conducts power
And it's not on by default if it's just sitting on its own somewhere
2021-02-16 17:18:49 +11:00
Timothy Stiles
e866710af6 turntables can have wires run to them like blue teles 2021-02-16 16:44:38 +11:00
Timothy Stiles
5551e546de oops fix crash 2021-02-16 16:29:43 +11:00
Timothy Stiles
62a3ed99aa Implement Blue teleporter exit
A blue teleporter for all intents and purposes except it can only be exited, not entered.
2021-02-16 16:21:12 +11:00
Timothy Stiles
b7cedc2426 refactor speed shoes so they stack with dash floor/sand instead of overriding 2021-02-16 15:59:34 +11:00
Timothy Stiles
e0004fb840 Implement Dash Floor
Anything walking on it moves at double speed.
2021-02-16 15:10:05 +11:00
Timothy Stiles
3d21277593 spaceify 2021-02-16 13:09:17 +11:00
Timothy Stiles
4943759cd3 wireables wire/unwire whenever circuitry recalculates
e.g.
move a circuit block pointing to a transmogrifier: the transmogrifier stops moving unless it's powered
move it away: it goes back to always-on behaviour
2021-02-16 13:06:45 +11:00
Timothy Stiles
946a889159 terraformers terraforming extended terrain properties 2021-02-16 12:52:07 +11:00
Timothy Stiles
e60423e8c0 oh add_press_ready can happen before OR after on_begin, that's annoying
I'll just have to make it so transmuting a trap to non-trap then back to trap might leave it in a weird amount-of-presses state if you do Shenanigans until I can think of a better solution
2021-02-16 12:31:52 +11:00
Timothy Stiles
64e51d6a62 creating a powerable recalculates circuitry 2021-02-16 12:21:27 +11:00
Timothy Stiles
a73c34e576 traps remember their prior press count through transmutes 2021-02-15 21:39:33 +11:00
Timothy Stiles
8cbba99c0c Implement Item Lock
When placed atop an item, you must have that item to enter the tile. When you do, pay the item and destroy the item lock. Also can be placed on top of a bonus, and you must pay that amount of bonus to enter.
2021-02-15 21:27:56 +11:00
Timothy Stiles
9e2575cae4 handle circuit block undoing pretty well 2021-02-15 15:59:28 +11:00
Timothy Stiles
1bd165ad35 fix various Circuit Block cases 2021-02-15 15:37:03 +11:00
Timothy Stiles
3c6bff77d6 re-arrange experimental tab in a more natural order 2021-02-15 14:25:08 +11:00
Timothy Stiles
dfb31207ba remove 'transmuted connectable connects buttons to it' mechanic 2021-02-15 14:22:13 +11:00
Timothy Stiles
4097aa6e84 runtime circuit updates - Circuit Block works once more 2021-02-14 23:02:23 +11:00
Timothy Stiles
dddde89b03 fix a transmute-to-force-floor time travel bug 2021-02-14 22:15:05 +11:00
Timothy Stiles
456ebc334b circuit blocks make wired floor in water 2021-02-14 22:10:30 +11:00
Timothy Stiles
86404dbc5b spontaneously appearing button/buttonable fixes 2021-02-14 22:07:20 +11:00
Timothy Stiles
7d36d09715 terraforming a new button/buttonable now connects it 2021-02-14 21:47:04 +11:00
Timothy Stiles
11747f0d6e Implement Diode
Emits power only when receiving power. (Effectively, this delays power by one frame.)

Also I made it so circuit blocks clone properly
2021-02-14 19:54:53 +11:00
Timothy Stiles
931f3c19c7 fix lit dynamite crash 2021-02-14 19:27:06 +11:00
Timothy Stiles
d6e43a70ca fix ice skates/ice corners regression 2021-02-14 19:13:55 +11:00
Timothy Stiles
e699172b85 update static_on_tic_tiles when we transmute 2021-02-14 19:00:58 +11:00
Timothy Stiles
42560626bf fix Global Cycler / Frame Block bug 2021-02-14 18:45:42 +11:00
Timothy Stiles
20dad5c76a Implement Boulder
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.
2021-02-14 18:38:56 +11:00
Timothy Stiles
7bcb1ac018 Implement Spikes
Stops players (and doppelgangers) unless they have hiking boots. Everything else can pass.
2021-02-14 17:04:21 +11:00
Timothy Stiles
dece34f365 implement x5 Bonus
also make it so bonuses of 1mil+ are rendered reasonably in-game
(100bil+ starts breaking the results screen but I think that's lower priority)
2021-02-14 16:43:59 +11:00
Timothy Stiles
f64302f324 Implement Cracked Ice
Cracked Ice: Turns into water when something steps off of it (except ghosts).
Also had to implement slide_ignores/item_slide_ignores since I needed a way to ignore static aspects of the tile without preventing its functions from being called. there's probably a better way IDK
2021-02-14 15:48:20 +11:00
Timothy Stiles
c27af789cb Implement Hole and Cracked Floor
Hole: A bottomless pit. Destroys everything (except ghosts).
Cracked Floor: Turns into a hole when something steps off of it (except ghosts).
2021-02-14 15:12:21 +11:00
Timothy Stiles
1f6c86c146 Implement Electrified Floor
Conducts power (like a blue teleporter). While powered, destroys anything not wearing lightning boots (except dirt blocks).
2021-02-14 14:22:28 +11:00
Timothy Stiles
d874e4d956 colourify turntables 2021-02-08 16:15:18 +11:00
Timothy Stiles
5cc5370817 Global Cyclers can cycle actors now too 2021-02-07 23:19:33 +11:00
Timothy Stiles
f79a8cc259 no idea how this was even working still lmao 2021-02-07 21:58:10 +11:00
Timothy Stiles
1040646393 implement the Turntable
Rotates anything entering this tile (counter)clockwise. Frame blocks are rotated too. If connected to wire, only functions while receiving power.
2021-02-07 21:47:52 +11:00
Timothy Stiles
76c34007a2 global cyclers don't attempt to turn terrain into global cyclers 2021-02-07 11:25:19 +11:00
Timothy Stiles
7c306e2234 make global cyclers immune to cycling too 2021-02-07 10:52:18 +11:00
Timothy Stiles
19354bf5cf global cycler no longer wraps 2021-02-07 10:37:42 +11:00
Timothy Stiles
bd2364cbff flip a boolean 2021-02-07 10:26:27 +11:00
Timothy Stiles
e63544ec55 fix saving 2021-02-07 10:14:18 +11:00
Timothy Stiles
477cf804af terraformer no longer overrides items 2021-02-07 10:02:53 +11:00
Timothy Stiles
4f0ff2b346 Terraformer alt behaviour when it has an item on top
When activated, if there's an item on its tile, copies the item to the tile in front of it. Otherwise, copies the item AND terrain BEHIND it to the tile in front of it.
2021-02-06 23:08:39 +11:00
Timothy Stiles
0100f1e12c spaceify 2021-02-06 16:07:40 +11:00
Timothy Stiles
e134b4cbd9 Implement the Dormant Lava
Acts like dirt. However, fireballs will enter it and turn it into Fire in the process.
2021-02-06 16:07:10 +11:00
Timothy Stiles
ba5d6c966c Halo revive sound effect 2021-02-06 12:15:12 +11:00
Timothy Stiles
8ffc6e1127 implement the Halo
Protects the player from death once, destroying the would-be killer in the process.
2021-02-06 12:00:45 +11:00
Timothy Stiles
4b63b4f65f global cyclers don't cycle ANY global cycler's surrounding tiles 2021-02-05 16:15:53 +11:00
Timothy Stiles
81fd712adc global cyclers don't cycle their surrounding tiles, just everythng else 2021-02-05 15:49:42 +11:00
Eevee (Evelyn Woods)
b97f99cbaf Fix Lexy loop to update wiring thrice per tic, not twice 2021-02-04 21:43:31 -07:00
Timothy Stiles
bddde32325 implement the Global Cycler
When activated, every terrain/item on the surrounding four tiles in the entire level becomes the terrain/item one clockwise. Adjacent tiles with a 'no sign' on them are ignored. Two of the same tile in a row mean that tile will not be transformed and will stay as-is.
2021-02-05 15:26:00 +11:00
Eevee (Evelyn Woods)
54d38527f2 Disallow taking yellow teleporters if the level only started with one (fixes #30) 2021-02-04 21:09:01 -07:00
Timothy Stiles
9b76c6b9ce implement the Terraformer
The Terraformer is like a clone machine for everything that isn't an actor. It copies the terrain (and item) behind it to in front of it when powered.
2021-02-05 14:41:11 +11:00
Eevee (Evelyn Woods)
c7012f2565 Allow simple item bestowal when facing the level edge, too 2021-02-04 20:34:53 -07:00
Timothy Stiles
da18684cbb add some tiles 2021-02-05 13:20:41 +11:00
Eevee (Evelyn Woods)
d5fd7b546d Fix ghosts to not detonate green mines 2021-02-01 22:46:08 -07:00
Eevee (Evelyn Woods)
d8ac50efa9 Implement the CC1 inventory 2021-02-01 22:06:34 -07:00
Eevee (Evelyn Woods)
51acfc4353 Remove a hack for the old Lexy loop that's no longer necessary 2021-01-25 17:22:28 -07:00
Eevee (Evelyn Woods)
bf51cc2e0b Only "mmf" once per attempted move 2021-01-25 17:22:12 -07:00
Eevee (Evelyn Woods)
b87ce730f2 Don't blow up players that start on mines by default; fixes CCLP4 #38 2021-01-25 16:54:09 -07:00
Eevee (Evelyn Woods)
4e83d7a3fd Fix splash page stuff leaking into overlays 2021-01-25 15:29:29 -07:00
Eevee (Evelyn Woods)
5b3cc62c8c Put the debug speed buttons in increasing order 2021-01-25 15:27:26 -07:00
Eevee (Evelyn Woods)
acfad66974 Add undo/redo support to the editor 2021-01-25 15:26:56 -07:00
Eevee (Evelyn Woods)
884d6d9164 Restore manually-saved doppelganger behavior, to make it work with undo 2021-01-25 12:29:18 -07:00
Eevee (Evelyn Woods)
0b6ea68a7b Fix rendering of crossed wires 2021-01-22 09:30:12 -07:00
Eevee (Evelyn Woods)
0c774d343e Fix several renderer interpolation bugs 2021-01-22 09:12:02 -07:00
Eevee (Evelyn Woods)
69a344595c Fix hints lingering forever 2021-01-22 08:46:23 -07:00
Eevee (Evelyn Woods)
54823f62bf Add some more actor inspection to debug mode 2021-01-22 08:37:14 -07:00
Eevee (Evelyn Woods)
134270e3e3 Add a couple CC1 compat flags 2021-01-20 14:45:30 -07:00
Eevee (Evelyn Woods)
43168f75cd Update the OpenGraph desc, now that we're nearly done 2021-01-20 13:48:42 -07:00
Eevee (Evelyn Woods)
75d7691925 Add some more sound effects 2021-01-20 13:42:27 -07:00
Eevee (Evelyn Woods)
652e7e8108 Show time improvement on the level tally 2021-01-20 12:36:02 -07:00
Eevee (Evelyn Woods)
d21bfd4601 Fix a typo when moving levels 2021-01-20 12:00:47 -07:00
Eevee (Evelyn Woods)
7cb2d949db Clarify flame jet tooltips 2021-01-20 12:00:37 -07:00
Eevee (Evelyn Woods)
ff33c42cc2 Keep the editor's level browser dialog around, and render previews faster
It's kind of annoying to watch the previews flicker into existence anew
every time you open the dialog, if you're working on several levels at a
time.
2021-01-20 11:59:29 -07:00
Eevee (Evelyn Woods)
1e38ccdc30 Fix door collision to act like dirt and not check has_inventory (fixes #29) 2021-01-17 23:21:12 -07:00
Eevee (Evelyn Woods)
32a5bc31bb Fix dynamite leaving fire under actors beneath canopies (fixes #27) 2021-01-16 20:56:16 -07:00
Eevee (Evelyn Woods)
6a2d6d608d Disable animation on inactive red teleporters and transmogrifiers (fixes #28) 2021-01-16 20:53:30 -07:00
Eevee (Evelyn Woods)
5653fc9c12 Change the "more" link text to bypass Vivaldi's fast forward feature 2021-01-16 02:58:11 -07:00
Eevee (Evelyn Woods)
b9037c1ce1 Remember last opened level in editor; prevent deleting the current level 2021-01-16 02:50:52 -07:00
Eevee (Evelyn Woods)
ac9b702eaa Add controls for rearranging, duplicating, and deleting levels 2021-01-16 01:45:57 -07:00
Eevee (Evelyn Woods)
f89cccedb2 Pin the fflate version; 0.5.3 is unusable 2021-01-14 04:35:08 -07:00
Eevee (Evelyn Woods)
cf72daacbe Checkpoint some minor editor bits 2021-01-14 00:53:54 -07:00
Eevee (Evelyn Woods)
5ab45b95c6 Prevent blowing up the same block twice in one tic, take two 2021-01-14 00:51:20 -07:00
Eevee (Evelyn Woods)
6e7338a214 Prevent blowing up the same block twice in one tic 2021-01-14 00:47:44 -07:00
Eevee (Evelyn Woods)
570fad84ab Fix the player not being all the way on the exit when winning 2021-01-13 23:48:24 -07:00
Eevee (Evelyn Woods)
788e4ec3bc Fix spacebar at the end of a level skipping the title screen for the next level 2021-01-13 23:43:54 -07:00
Eevee (Evelyn Woods)
fcab03f1d1 Fix some nits with the level browser header 2021-01-13 22:52:57 -07:00
Eevee (Evelyn Woods)
a91e1a831e Track best score/clock/time separately; add a summary row to the level browser 2021-01-13 22:49:34 -07:00
Eevee (Evelyn Woods)
22f78f171c Fix counting of aidless levels; remove total_time 2021-01-13 22:12:53 -07:00
Eevee (Evelyn Woods)
5df34712b6 Plurals 2021-01-13 21:56:34 -07:00
Eevee (Evelyn Woods)
db9ef8e51d Show something useful for old save files 2021-01-13 21:51:25 -07:00
Eevee (Evelyn Woods)
3aec2b1fe6 Spruce up stock pack list with previews, progress bar 2021-01-13 21:49:01 -07:00
Eevee (Evelyn Woods)
bb7c468174 Finish special-ization of tileset defs; add depressed versions of buttons 2021-01-13 03:33:43 -07:00
Eevee (Evelyn Woods)
62eb4dc4bd Remove the old Lexy-mode hack that only existed to try to make CC1 sync 2021-01-13 02:01:04 -07:00
Eevee (Evelyn Woods)
ed814cbf60 Add a compat flag for the MSCC ice patch behavior 2021-01-13 01:55:04 -07:00
Eevee (Evelyn Woods)
1650a3fc94 Add new, smoother pulling behavior as the Lexy default 2021-01-13 01:46:47 -07:00
Eevee (Evelyn Woods)
b6ed3b6502 Change the Lexy loop to be more Lynx-like
This simplifies the renderer by having movement cooldowns only work one
way, and thus removes the jank from Steam rendering.

This commit also applies cooldowns for animations at decision time, as
Lynx does, which eliminates a weird special case from their spawning.

Also, Lexy mode now explicitly does not allow an actor to get cooled
twice in one tic.  However, this change does make clone machines no
longer be aligned with the thing that pressed the button to clone them,
which is unfortunate.
2021-01-13 01:34:08 -07:00
Eevee (Evelyn Woods)
a1041c3e6f Improve appearance of scrollbar in long hints 2021-01-11 18:14:27 -07:00
Eevee (Evelyn Woods)
0f6f912055 Fix spacebar being eaten by Vivaldi, and repeated arrow keys causing scrolling 2021-01-11 18:10:34 -07:00
Eevee (Evelyn Woods)
602f16be8c Update auto-scaling to match new DOM; introduce a landscape layout with more vertical space 2021-01-11 18:06:09 -07:00
Eevee (Evelyn Woods)
83f0ac9813 Always update the total levels in a pack when saving scores 2021-01-11 01:34:14 -07:00
Eevee (Evelyn Woods)
73fff50a00 Reduce the amount of mmfing allowed in a row 2021-01-11 01:34:04 -07:00
Eevee (Evelyn Woods)
2f9b0c1154 Add obituaries for CC2 monsters, and give existing causes a few more 2021-01-11 01:33:52 -07:00
Eevee (Evelyn Woods)
dfed3f2db9 Fix copied floated selections being entangled 2021-01-11 01:30:16 -07:00
Eevee (Evelyn Woods)
fbf3cb5ae2 Add a WIP version of Lexy's Lessons as a canon pack 2021-01-11 00:57:50 -07:00
Eevee (Evelyn Woods)
c2ed444ca0 Fix the drawing of copying a floating selection 2021-01-11 00:23:18 -07:00
Eevee (Evelyn Woods)
6a6a3a212e Show time and level count in the splash's list of editor packs 2021-01-11 00:17:40 -07:00
Eevee (Evelyn Woods)
a2ec070a32 Play an explosion sound when a flame jet destroys something 2021-01-10 14:45:00 -07:00
Eevee (Evelyn Woods)
9efe3d00ef Fix perception 2021-01-10 14:44:42 -07:00
Eevee (Evelyn Woods)
b4acc74e0a Explicitly allow VFX to erase other VFX (otherwise undo breaks!) 2021-01-10 14:44:23 -07:00
Eevee (Evelyn Woods)
fb1e749a28 Fix a couple places the editor wasn't redrawing 2021-01-10 14:38:37 -07:00
Eevee (Evelyn Woods)
69296dff67 Make ending more readable, less CPU-heavy 2021-01-09 12:46:43 -07:00
Eevee (Evelyn Woods)
723af175cb Add ending artwork 2021-01-09 03:05:50 -07:00
Eevee (Evelyn Woods)
bfacde7525 Do something vaguely interesting when the game ends 2021-01-08 22:38:48 -07:00
Eevee (Evelyn Woods)
e64a553365 Add a focus trap for overlays, and close them with Esc 2021-01-08 22:00:59 -07:00
Eevee (Evelyn Woods)
246ef468de Fix rendering of tiles above the actor layer 2021-01-08 17:57:04 -07:00
Eevee (Evelyn Woods)
c6594712df Play the floor step sound by default 2021-01-08 16:28:52 -07:00
Eevee (Evelyn Woods)
8f40f575bf Introduce a DrawPacket to consolidate draw arguments; fix blurriness of double-size monsters 2021-01-08 16:28:08 -07:00
Eevee (Evelyn Woods)
c60158cc47 Ensure ghosts can never drown 2021-01-08 15:47:39 -07:00
Eevee (Evelyn Woods)
fc6b7472b6 Fix some subtle issues with changing tilesets twice in one session 2021-01-08 15:31:25 -07:00
Eevee (Evelyn Woods)
30c17c0c8b Fix undo failure after being killed by a cloner; ensure the failure reason is assigned to the right player 2021-01-08 15:16:25 -07:00
Eevee (Evelyn Woods)
560fd93c8b Reveal popdown floors when there's an item on them, too 2021-01-08 15:01:30 -07:00
Eevee (Evelyn Woods)
67c53f97dd Show the level title and author (if available) on start 2021-01-07 18:18:51 -07:00
Eevee (Evelyn Woods)
cc48136d94 Fix the editor's force floor tool 2021-01-07 18:00:29 -07:00
Eevee (Evelyn Woods)
7ceab97472 Fix the editor's partial redrawing; place popup editors by bbox, not mouse position 2021-01-07 17:55:11 -07:00
Eevee (Evelyn Woods)
f389f4d027 Fix disabling the next-level button 2021-01-07 14:01:24 -07:00
Eevee (Evelyn Woods)
c162445627 Always play the get-chip sound 2021-01-06 22:58:54 -07:00
Eevee (Evelyn Woods)
f788f7a892 Apply a quick hack to fix ?level URLs being blank 2021-01-06 19:09:57 -07:00
Eevee (Evelyn Woods)
f35da9cc2b Finally populate the options dialog, with volume controls and tileset selection 2021-01-06 19:04:28 -07:00
Eevee (Evelyn Woods)
04940ff42c Finally fix display of long hints 2021-01-05 23:46:00 -07:00
Eevee (Evelyn Woods)
a7310cf59b Fix bad editor state caused by copying a selection 2021-01-05 22:38:05 -07:00
Eevee (Evelyn Woods)
3d0142310e Spawn an explosion when destroying an actor in fire 2021-01-05 22:23:47 -07:00
Eevee (Evelyn Woods)
999467bb1f Ignore spurious kinds of keydown events 2021-01-05 22:07:04 -07:00
Eevee (Evelyn Woods)
b5b7ccbc46 Move lit dynamite to the actor layer so it destroys itself again 2021-01-05 21:04:20 -07:00
Eevee (Evelyn Woods)
0ba112aec5 Don't gripe about VFX erasing each other; that's a feature 2021-01-05 21:04:09 -07:00
Eevee (Evelyn Woods)
04e350b624 Fix released key detection 2021-01-05 21:03:53 -07:00
Eevee (Evelyn Woods)
5c1b2dbd9d Update hint when switching players; parse the keybind placeholders 2021-01-05 20:44:11 -07:00
Eevee (Evelyn Woods)
c6d9eb3271 Fix ctrl-click not working quite right on Macs 2021-01-05 17:18:09 -07:00
Eevee (Evelyn Woods)
05e8f05b41 Correct and fill in a couple editor tile tooltips 2021-01-05 17:16:38 -07:00
Eevee (Evelyn Woods)
aed96c8e41 Add a bunch of minor rendering stuff
- Added the active player background

- Added bomb fuses (though LL doesn't use them)

- Added CC2-style double-size blob and walker (though LL doesn't use them)

- Added the rover's directional overlay

- Added custom push animations

- Added custom bouncing heart animations

- Added a puff when opening a door or socket, or revealing a fake floor

- Fixed the rover's animations being a bit mixed up

- Fixed player walk animations occasionally being glitchy

- Touched up the fake floor x-ray tile

- Touched up the canopy x-ray tile

- Touched up the purple ball's shadows

- Touched up the transmogrifier and transmogrify flash
2021-01-05 17:10:21 -07:00
Eevee (Evelyn Woods)
31a1049655 Fix x-ray glasses breaking the renderer 2021-01-04 17:59:24 -07:00
Eevee (Evelyn Woods)
fda1c6c66e Fix drag and drop with directories; improve drop zone 2021-01-04 12:56:30 -07:00
Eevee (Evelyn Woods)
f03144ba91 Fix editing wire tunnels 2021-01-03 19:45:34 -07:00
Eevee (Evelyn Woods)
a72ec8c476 Fix undoing dropping a bowling ball; make bowling balls explode at level edge 2021-01-03 19:31:59 -07:00
Eevee (Evelyn Woods)
a4c1aa869b LAYER => LAYERS, oops 2021-01-03 19:15:38 -07:00
Eevee (Evelyn Woods)
683ab6a2c9 Fix some fallout from all that editor rendering stuff 2021-01-03 18:43:24 -07:00
Eevee (Evelyn Woods)
1ce704864c Teach the editor to do partial redraws 2021-01-03 18:21:52 -07:00
Eevee (Evelyn Woods)
90fa352a50 Split out editor drawing and slightly speed up normal drawing 2021-01-03 18:03:58 -07:00
Eevee (Evelyn Woods)
6fc4f6b58f Change StoredCell to also be layered, and update the editor to match 2021-01-03 17:44:16 -07:00
Eevee (Evelyn Woods)
323ed3ee18 Refactor to using cells with fixed slots
This better matches CC2 behavior and also makes some very common
operations, like grabbing a cell's actor or terrain, way faster.

It also allows me to efficiently implement CC2's layer order when
checking for collisions; thin walls are checked before terrain, and
actors only afterwards.  The upshot is that bowling balls no longer
destroy stuff on the other side of a thin wall!

I also did some minor optimizing, mostly by turning loops over an entire
cell's contents into checks for a single layer; Chromium now performs a
bulk test about 30% faster.

Downsides of this change:
- All kinds of stuff may have broken!
- It'll be a little difficult to ever emulate MSCC's curious behavior
  when stacking terrain on top of items or other terrain.  But not
  impossible.
- It'll be far more difficult to emulate buggy Lynx (or maybe it's just
  Tile World?) behavior where some combination of cloners and teleports
  allow a ton of monsters to accumulate in a few cells.  I guess I
  wasn't planning on doing that anyway.
2021-01-03 17:19:27 -07:00
Eevee (Evelyn Woods)
cff756597c Fix a few places where two tiles on the same layer could coexist in a cell 2021-01-03 15:18:53 -07:00
Eevee (Evelyn Woods)
fe7731efe7 Fix minor issues: ghosts don't affect popwalls, voodoo tiles shouldn't crash, on_bump is unused 2021-01-03 14:06:41 -07:00
Eevee (Evelyn Woods)
c6c3ff2d71 Fix losing slide mode when teleporting on a yellow teleport you just dropped 2021-01-03 14:06:04 -07:00
Eevee (Evelyn Woods)
0f1afbb877 Teach format_duration to handle negative durations 2021-01-03 13:48:23 -07:00
Eevee (Evelyn Woods)
9cf2b82c8e Change the bulk test results into a (slightly more compact) table 2021-01-03 13:48:11 -07:00
Eevee (Evelyn Woods)
d1646532d5 Draw the countdown frames for the time bomb, and improve its artwork 2021-01-03 13:20:28 -07:00
Eevee (Evelyn Woods)
0e1bd91075 Add a bunch more sound effects 2021-01-03 13:07:15 -07:00
Eevee (Evelyn Woods)
6446a4654b Fix the order of operations for slide mode, again
This fixes an obscure bug where you could redirect a block that was in
the process of sliding off of ice onto floor, because its slide mode had
not yet been cleared.
2021-01-02 23:47:49 -07:00
Eevee (Evelyn Woods)
b08750696e Implement replay-compatible blob transmogrifying 2021-01-02 19:50:03 -07:00
Eevee (Evelyn Woods)
ed6a98392d Fix the fix for bowling balls destroying players 2021-01-02 17:42:39 -07:00
Eevee (Evelyn Woods)
656d124c89 Fix a crash when blowing up a pink button 2021-01-02 17:42:07 -07:00
Eevee (Evelyn Woods)
6c2602246e Allow rovers to enter popwalls and fake walls 2021-01-02 17:16:27 -07:00
Eevee (Evelyn Woods)
c9bcc92bdf Prevent ghosts from toggling swivels 2021-01-02 16:59:20 -07:00
Eevee (Evelyn Woods)
6d519cfa0a Handle a player blowing themselves up with dynamite 2021-01-02 12:23:53 -07:00
Eevee (Evelyn Woods)
69d62f8266 Fix collision masks for dynamite and bowling balls 2021-01-02 12:19:52 -07:00
Eevee (Evelyn Woods)
a657682035 Disable zlibbed level URLs since they'll break gliderbot 2021-01-01 22:26:29 -07:00
Eevee (Evelyn Woods)
29df283f80 Add support for zlib-compressed levels in URLs 2021-01-01 22:09:49 -07:00
Eevee (Evelyn Woods)
e277a1363e Add a transmogrify sparkle 2021-01-01 21:27:10 -07:00
Eevee (Evelyn Woods)
a41baee3fc Fix this fix to player fixing whatever 2021-01-01 20:18:03 -07:00
Eevee (Evelyn Woods)
21286920a2 Add ZIP to the file upload control's extensions 2021-01-01 20:15:36 -07:00
Eevee (Evelyn Woods)
2673f7f9f8 Don't switch players when a non-active player exits 2021-01-01 20:10:00 -07:00
Eevee (Evelyn Woods)
d77b25c7c1 Wildly guess about how blob transmogrification might use the PRNG 2021-01-01 20:09:44 -07:00
Eevee (Evelyn Woods)
65535eaded Give that "more levels" header some breathing room 2021-01-01 18:32:31 -07:00
Eevee (Evelyn Woods)
80ef57b0b8 Update README to reflect current status 2021-01-01 18:27:34 -07:00
Eevee (Evelyn Woods)
c55a415099 Support drag and drop with a single CCL or ZIP 2021-01-01 18:25:20 -07:00
Eevee (Evelyn Woods)
1b55e82061 Add CC2LP1; streamline the splash page, moving most of the text to the GitHub wiki 2021-01-01 18:18:50 -07:00
Eevee (Evelyn Woods)
1b48c291c9 Implement the pgchip clone block ice cloning hack 2021-01-01 17:30:50 -07:00
Eevee (Evelyn Woods)
aac1e09c72 Add support for pgchip's magic number and ice block encoding 2021-01-01 17:11:11 -07:00
Eevee (Evelyn Woods)
0d35274d6a Add support for loading CC2 ZIPs, and parse out C2G game titles 2021-01-01 15:26:33 -07:00
Eevee (Evelyn Woods)
f5b1b4a83d Make multi-hints readable by CC2 2021-01-01 14:46:05 -07:00
Eevee (Evelyn Woods)
044c08c3fc Add support for downloading an entire pack as a zipped C2G 2021-01-01 14:44:48 -07:00
Eevee (Evelyn Woods)
83793603d3 Improve interactions between turn-based mode and tic navigation 2021-01-01 13:10:48 -07:00
Eevee (Evelyn Woods)
adac6774a4 Remove duplicate code in Tileset drawing 2021-01-01 12:47:54 -07:00
Eevee (Evelyn Woods)
c8686f9d66 Copy a level's title to the right places in the editor so it shows in the level browser 2021-01-01 12:44:20 -07:00
Eevee (Evelyn Woods)
4454970564 Split up the Steam loop into begin/finish parts to match what turn-based mode expects 2020-12-31 18:01:40 -07:00
Eevee
f0cd4d3c5a
Merge pull request #22 from Patashu/master
fix turn-based mode
2020-12-31 17:23:24 -07:00
Eevee (Evelyn Woods)
18b9fd6d4d Add support for a floating selection 2020-12-31 14:49:37 -07:00
Eevee (Evelyn Woods)
2183e7de3c Fix some copy/paste errors in editor tooltips 2020-12-30 18:50:17 -07:00
Eevee (Evelyn Woods)
130b917c81 Lazily load the level in the player and editor, so an unplayable level doesn't break the editor too 2020-12-30 18:21:21 -07:00
Eevee (Evelyn Woods)
93d77ea297 Add support for "rotating" through letter tiles. Fixes #21 2020-12-30 17:57:07 -07:00
Eevee (Evelyn Woods)
2109e4f4fa Fix paths to rotation buttons 2020-12-30 17:50:34 -07:00
Eevee (Evelyn Woods)
15a37457de Add physical rotation buttons 2020-12-30 17:47:29 -07:00
Eevee (Evelyn Woods)
fb2f79823c Stub out a selection tool for the editor 2020-12-30 17:28:41 -07:00
Eevee (Evelyn Woods)
d700561c0f Add a cursor to the editor 2020-12-30 11:52:27 -07:00
Eevee (Evelyn Woods)
09d220b2a3 Improve ergonomics of the level props dialog a bit 2020-12-30 11:30:50 -07:00
Eevee (Evelyn Woods)
48803b1483 Add tooltip help for most tiles in the editor 2020-12-30 11:08:38 -07:00
Eevee (Evelyn Woods)
de53582d47 Fix perception and use it to implement the xray eye 2020-12-29 20:42:53 -07:00
Eevee (Evelyn Woods)
746300a514 Add support for encoding thin walls/canopies; add them to the editor; add support for additive drawing 2020-12-29 20:12:54 -07:00
Eevee (Evelyn Woods)
b9a311a18c Merge thin walls into a single tile; split "overlay" layer into correct CC2 parts 2020-12-29 19:29:22 -07:00
Timothy Stiles
f6a79456e9 fix turn-based mode 2020-12-30 13:01:01 +11:00
Eevee (Evelyn Woods)
1c5f63b61b Add a new experimental tile: sand 2020-12-29 17:08:51 -07:00
Eevee (Evelyn Woods)
6b0bb9cb3d Prevent pushing a block that's already moving due to a slide push 2020-12-29 12:30:08 -07:00
Eevee (Evelyn Woods)
c475500bdb Add in a single-tic delay after a failed teleport 2020-12-29 11:42:41 -07:00
Eevee (Evelyn Woods)
be769b7dc8 Clean up doppelganger collision; monsters block doppelgangers, but players and dopps don't block each other 2020-12-29 11:34:51 -07:00
Eevee (Evelyn Woods)
819a2e2203 Make actors bonk on ice even if they weren't already sliding 2020-12-29 10:16:10 -07:00
Eevee (Evelyn Woods)
a32b29976e Fix occasionally displaying times as 1:010 2020-12-29 10:15:57 -07:00
Eevee (Evelyn Woods)
6c99752f37 Allow the hook to block monster movement at decision time 2020-12-29 09:03:40 -07:00
Eevee (Evelyn Woods)
41ab804f79 Prevent blocks from pushing sliding blocks, except frame blocks pushing dirt blocks 2020-12-29 08:30:55 -07:00
Eevee (Evelyn Woods)
63f09283ea Make actors explode when starting the level on a bomb 2020-12-29 07:21:18 -07:00
Eevee (Evelyn Woods)
0561e15d0a Move hooking to decision time while fixing the swivel following problem 2020-12-29 07:04:55 -07:00
Eevee (Evelyn Woods)
2c1d047f4b Don't let animations block actors with helmets 2020-12-28 15:03:40 -07:00
Eevee (Evelyn Woods)
f54edf8692 Fix the way force floors alter actors' movement when flipping under CC2 rules 2020-12-28 15:03:25 -07:00
Eevee (Evelyn Woods)
f6f83a45f1 Add a teleport flash 2020-12-28 14:21:28 -07:00
Eevee (Evelyn Woods)
1e79704f70 Add more experimental tiles: gates and a skeleton key (also some tileset touchups) 2020-12-28 14:00:47 -07:00
Eevee (Evelyn Woods)
fa47c28136 Fix some omissions with saving stats for custom level packs 2020-12-28 10:20:08 -07:00
Eevee (Evelyn Woods)
dee46b77df Fix black buttons to /always/ separate horizontal and vertical wires 2020-12-28 06:53:19 -07:00
Eevee (Evelyn Woods)
b72e20a4a5 Round the bonus down when halving it 2020-12-27 18:26:49 -07:00
Eevee (Evelyn Woods)
a2914cc291 In Lynx update mode, only tick an actor's cooldown after an extra move if that move succeeded 2020-12-27 17:32:55 -07:00
Eevee (Evelyn Woods)
d10cba7935 Make bowling balls not detonate if bumped by an actor who can't enter the cell 2020-12-27 09:33:02 -07:00
Eevee (Evelyn Woods)
2c9fbbba50 Make ghosts bonk on ice corners too 2020-12-27 09:32:37 -07:00
Eevee (Evelyn Woods)
b9f31b4170 Attempt to fix the swap button flickering like mad 2020-12-27 08:51:12 -07:00
Eevee (Evelyn Woods)
1fc8e35843 Run on_ready in reverse order, mostly so initial RFF directions are correct 2020-12-27 08:33:03 -07:00
Eevee (Evelyn Woods)
66ca5f5fff Prevent pickup up a fifth tool when unable to drop one (because it's a yellow teleport and you're not on floor) 2020-12-27 08:05:38 -07:00
Eevee (Evelyn Woods)
f30b9b34dd Prevent bumping tiles in a cell that's blocked by thin walls 2020-12-27 08:05:03 -07:00
Eevee (Evelyn Woods)
bf952433f1 Allow anything to pull blocks with the hook 2020-12-27 07:41:11 -07:00
Eevee (Evelyn Woods)
6ab3ff9b0b Fix treating a tile as wired if it had an adjacent wire with a tunnel running under it 2020-12-27 07:32:28 -07:00
Eevee (Evelyn Woods)
8211da6cc3 Make gravel and dirt also block doppelganger Cerise 2020-12-27 07:28:38 -07:00
Eevee (Evelyn Woods)
4e5b2f02d9 Make yellow tanks remember a yellow button press made while they were sliding 2020-12-27 07:20:33 -07:00
Eevee (Evelyn Woods)
934a2ec1fa Switch railroad tracks when an actor with the RR sign makes a legal move 2020-12-27 07:08:23 -07:00
Eevee (Evelyn Woods)
d4fab4fba2 Restart a "waiting" level when changing compat mode; adjust player state when navigating with debug panel 2020-12-27 07:07:55 -07:00
Eevee (Evelyn Woods)
46a84e80b8 Fix some visual bugs with tracks in the editor 2020-12-27 07:07:23 -07:00
Eevee (Evelyn Woods)
f798bd2c9c Move blobs' slime-spreading to happen instantly, not on arrival (and fix some typos) 2020-12-27 06:17:52 -07:00
Eevee (Evelyn Woods)
effc709a01 Make the compat preset buttons all the same height 2020-12-27 05:41:32 -07:00
Eevee (Evelyn Woods)
6470575a7b Populate movement_cooldown for lit dynamite; guard against NaNs; check for moving blocks in bump mode 2020-12-27 05:41:03 -07:00
Eevee (Evelyn Woods)
c7815ba841 Move teleporter overriding to decision time; treat teleporting as a kind of slide; decouple speed from sliding 2020-12-27 05:40:06 -07:00
Eevee (Evelyn Woods)
adb0c4c869 Fix the search radius for orange buttons 2020-12-26 04:34:24 -07:00
Eevee (Evelyn Woods)
059a523347 Make ghosts erase fire even if they just got the boots from the same cell 2020-12-26 04:23:23 -07:00
Eevee (Evelyn Woods)
8fbd454059 Fix rendering in CC2 mode 2020-12-26 04:08:54 -07:00
Eevee (Evelyn Woods)
30b4b89a95 Change traps to not eject their contents when opened by wire 2020-12-26 03:59:21 -07:00
Eevee (Evelyn Woods)
a45a0138b9 Fix flipping force floors with something on them
Using `this` here doesn't work because the type just changed!
2020-12-26 03:58:40 -07:00
Eevee (Evelyn Woods)
ab22c6ff3f Fix actors trying to reverse when bonking on normal force floors 2020-12-26 03:41:58 -07:00
Eevee (Evelyn Woods)
9c2809be29 Fix interpolation after the tic -> frame change 2020-12-26 03:41:35 -07:00
Eevee (Evelyn Woods)
0500518537 Move sliding back to arrive time, but force floor bonking to movement time 2020-12-26 03:15:00 -07:00
Eevee (Evelyn Woods)
0bb3f78a33 Give flame jets their own mini-pass 2020-12-26 02:03:39 -07:00
Eevee (Evelyn Woods)
09c1976608 Make the bulk tester more aggressive (any more shows no further time gain) 2020-12-26 02:02:24 -07:00
Eevee (Evelyn Woods)
86bf90ee89 Don't let the last player walk back out of the exit 2020-12-24 10:18:45 -07:00
Eevee (Evelyn Woods)
a0b34217b4 Implement the goofy CC2 "open trap" tile 2020-12-24 09:44:29 -07:00
Eevee (Evelyn Woods)
019f6a78bc Move hook-pulling code to fix several ordering issues 2020-12-24 09:29:25 -07:00
Eevee (Evelyn Woods)
7b54f88981 Let dynamite blow up even wired floor 2020-12-24 09:20:14 -07:00
Eevee (Evelyn Woods)
d3067173d6 Don't search for an exit direction from the red teleporter you entered 2020-12-24 08:01:43 -07:00
Eevee (Evelyn Woods)
afe68e1b20 Fix fire to not kill ghosts, again 2020-12-24 07:57:18 -07:00
Eevee (Evelyn Woods)
1727df4e38 Allow ghosts to pass through chip sockets at any time 2020-12-24 07:08:12 -07:00
Eevee (Evelyn Woods)
effa166c67 Prevent bestowal of red keys 2020-12-24 07:06:04 -07:00
Eevee (Evelyn Woods)
0e1e577281 Allow doppelgangers to also erase animations 2020-12-24 07:03:26 -07:00
Eevee (Evelyn Woods)
d567a2553e Disallow pulling unpushable blocks; play a sound on bowling ball collision 2020-12-24 06:55:50 -07:00
Eevee (Evelyn Woods)
1a7dc3e737 Implement a couple more compat flags and fix some typos with the dialog 2020-12-24 06:30:22 -07:00
Eevee (Evelyn Woods)
d20b9bd825 Add another forgotten file... 2020-12-24 06:13:55 -07:00
Eevee (Evelyn Woods)
e630893bef Commit some compat icons I forgot about 2020-12-24 06:13:21 -07:00
Eevee (Evelyn Woods)
715d5412e4 Move these links out of the way for now 2020-12-24 06:11:35 -07:00
Eevee (Evelyn Woods)
1968420027 Improve the pack handling experience somewhat
- Include links for the stock packs

- Show completion amount and total time for played packs

- Expose a list of all other packs the player has played

- Allow forgetting a pack

- Jump to the current level when reopening a pack

- Highlight the current level in the level browser, and scroll to it
2020-12-24 05:36:57 -07:00
Eevee (Evelyn Woods)
a8800838d4 Add a loading and error screen; also include compat CSS oops 2020-12-24 03:38:13 -07:00
Eevee (Evelyn Woods)
756a563135 Add a (working!) compatibility dialog, and a ruleset dropdown to the bulk tester 2020-12-24 01:51:27 -07:00
Eevee (Evelyn Woods)
2381bd38b9 Add compat switches for using the CC2 timing and update order
Other gameplay changes/fixes that crept in:

- Ghosts no longer pick up red keys

- Doppelgangers now read their movement directly from players, so no
  intermediate variables are necessary

- Spring mining is no longer possible

- Push recursion is detected and prevented

- Bowling balls will also blow up anything that runs into them
2020-12-23 04:30:10 -07:00
Eevee (Evelyn Woods)
1aa406fc7b Move sliding effects to decision time 2020-12-21 03:50:03 -07:00
Eevee (Evelyn Woods)
bf743caee5 Fix regression with dropping items on force floors 2020-12-21 00:34:31 -07:00
Eevee (Evelyn Woods)
6ea46c238c Fix doppelganger moves lingering after a player switch/transmog 2020-12-21 00:29:14 -07:00
Eevee (Evelyn Woods)
42dd4b9ce6 Make ghosts with fire boots erase fire and blobs not move between canopies 2020-12-21 00:21:32 -07:00
Eevee (Evelyn Woods)
151f66a0fb Make blobs spread slime onto floor 2020-12-21 00:05:12 -07:00
Eevee (Evelyn Woods)
b0aeee6ff0 Allow swapping and cycling even while sliding 2020-12-21 00:04:51 -07:00
Eevee (Evelyn Woods)
077a809168 Possibly fix update rate to be more consistent 2020-12-20 20:23:59 -07:00
Eevee (Evelyn Woods)
74eaab3fde Allow disabling undo for circuitry, too 2020-12-19 21:21:28 -07:00
Eevee
8742e4de25
Merge pull request #15 from magical/undon't
Disable undo during bulk testing
2020-12-19 21:17:30 -07:00
Andrew Ekstedt
8986a497fd Disable undo during bulk testing
Undo generates a lot of garbage. Faster not to.

CC1 bulk test on my laptop:

Firefox 77 speeds up from 61s to 41s
Chrome barely moves, from 34s to 28s
2020-12-19 19:12:28 -08:00
Eevee (Evelyn Woods)
99af6025ee Fix ice blocks to play an animation when destroyed by fire 2020-12-19 19:50:20 -07:00
Eevee (Evelyn Woods)
53838cbdd5 Fix ghosts to ignore force floors too 2020-12-19 19:44:57 -07:00
Eevee (Evelyn Woods)
a413d1afc2 Fix bowling balls to start rolling immediately 2020-12-19 19:25:32 -07:00
Eevee (Evelyn Woods)
296d1a356b Fix wired cloner rotation to be permanent 2020-12-19 19:17:14 -07:00
Eevee (Evelyn Woods)
9391052011 Fix a typo and make Cerise block players 2020-12-19 18:48:45 -07:00
Eevee (Evelyn Woods)
86c4561647 Add a basic implementation of the hook 2020-12-19 18:43:30 -07:00
Eevee (Evelyn Woods)
f6bf33274f Fix some collision issues with ghosts and timing of dynamite 2020-12-19 18:24:30 -07:00
Eevee (Evelyn Woods)
78800214d0 Rovers can pick up items and push blocks 2020-12-19 17:39:20 -07:00
Eevee (Evelyn Woods)
aa0bb5cbc2 Bowling balls still blow up actors when sliding 2020-12-19 17:33:26 -07:00
Eevee (Evelyn Woods)
148beb7d74 Implement all-players-exit behavior; touch up locks, buttons, logic gates; fix demo saving 2020-12-19 17:16:50 -07:00
Eevee (Evelyn Woods)
78f59b38c1 Rewrite wiring code and fix basically all issues with it; faster, undoable, etc. 2020-12-18 19:58:12 -07:00
Eevee (Evelyn Woods)
48f085d0df Remove Level.cells in favor of linear_cells 2020-12-17 15:51:57 -07:00
Eevee (Evelyn Woods)
2fa35336cb Allow running in debug mode automatically when run from localhost 2020-12-17 14:47:30 -07:00
Eevee
7addaefbf0
Merge pull request #14 from magical/jumptotest
Minor quality of life improvements for the bulk tester
2020-12-17 00:01:34 -07:00
Andrew Ekstedt
cf90c7ac67 Minor quality of life improvements for the bulk tester
- Jump to result by clicking on the progress bar

- Include level titles in the progress bar hover text

Makes it a little easier to navigate lengthy test results.
2020-12-16 21:51:14 -08:00
Eevee (Evelyn Woods)
afec553961 Flame jets only kill actors that aren't moving 2020-12-16 21:25:01 -07:00
Eevee (Evelyn Woods)
7e262feeb6 Delete some old code 2020-12-16 21:24:03 -07:00
Eevee (Evelyn Woods)
6aed1fa38e Improve rotation of frame blocks on railroads 2020-12-16 21:23:37 -07:00
Eevee (Evelyn Woods)
4d5c1b4332 Flame jets destroy anything on them when turned on 2020-12-16 21:01:47 -07:00
Eevee (Evelyn Woods)
cace6d4180 Allow CC2 actions anytime the player can move, including on force floors 2020-12-16 20:53:42 -07:00
Eevee (Evelyn Woods)
c3889399fd Add support for wired transmogrifiers 2020-12-16 20:19:38 -07:00
Eevee (Evelyn Woods)
d4da572940 Fix rovers once and for all; make helmet work more often; rename some stuff; simplify attempt_step 2020-12-16 20:05:36 -07:00
Eevee (Evelyn Woods)
7cf92f7841 Set slide mode twice, to handle the obscure case of grabbing cleats on ice 2020-12-16 14:59:40 -07:00
Eevee (Evelyn Woods)
408e4cd9f6 Yellow tanks give up if blocked 2020-12-16 14:28:11 -07:00
Eevee (Evelyn Woods)
2eb7c4cff9 Fix some bugs in rover collision (and let them go on dirt/gravel); very very close now 2020-12-16 14:18:00 -07:00
Eevee (Evelyn Woods)
b42b091181 Fix yellow tank timing; make ghosts go through doors, not stamp dirt 2020-12-16 14:15:32 -07:00
Eevee (Evelyn Woods)
6587cbf7f0 Fix detecting presses of action keys, again 2020-12-16 01:39:17 -07:00
Eevee (Evelyn Woods)
af7d2c741b Give the bulk tester a category for early exit 2020-12-16 01:38:45 -07:00
Eevee (Evelyn Woods)
a91d7f24a1 Fix when we remember the player's move 2020-12-16 01:14:49 -07:00
Eevee (Evelyn Woods)
1021f30fb8 Partially fix rendering of crossed wires 2020-12-16 00:21:28 -07:00
Eevee (Evelyn Woods)
f0e702e397 Fix display of errored levels in bulk tester 2020-12-16 00:12:43 -07:00
Eevee (Evelyn Woods)
e48c2ed457 Doppelgangers block other doppelgangers, and cannot exit 2020-12-16 00:09:45 -07:00
Eevee (Evelyn Woods)
1273843f26 Add CC2's single-frame delay after a light switch is pressed 2020-12-15 23:43:21 -07:00
Eevee (Evelyn Woods)
22758e64ac Fix score bonus wrapping onto a new line 2020-12-15 23:01:52 -07:00
Eevee
341296dc3f
Merge pull request #11 from magical/grade
Add grade report to the bulk tester
2020-12-15 23:00:56 -07:00
Eevee (Evelyn Woods)
55f0d51e1c Borrow some comments from magical's PR that they did at the same time 2020-12-15 23:00:22 -07:00
Eevee (Evelyn Woods)
62eb1a86e4 Fix some minor wire bugs: NOT/counter/pink button power drawing, circuit block motion, nested wire tunnels 2020-12-15 22:57:32 -07:00
Eevee (Evelyn Woods)
3790e0f07e Allow wired cloners to try other directions 2020-12-15 21:27:58 -07:00
Eevee (Evelyn Woods)
daa3581be0 Fix pressing CC2 actions while moving 2020-12-15 21:14:38 -07:00
Andrew Ekstedt
55bf250c63 Add grade report to the bulk tester 2020-12-15 20:08:35 -08:00
Eevee (Evelyn Woods)
eec7ab2e1a Fix rover behavior at last; lesson 5 now syncs 2020-12-15 17:50:13 -07:00
Eevee (Evelyn Woods)
25b4b32f94 Add teleport overriding and seriously clean up teleport code 2020-12-15 16:44:37 -07:00
Eevee (Evelyn Woods)
7c82a4cdf9 Only do CC2 actions per press, not per held tic 2020-12-15 01:07:02 -07:00
Eevee (Evelyn Woods)
0f02e270f2 Catch level parse errors in the bulk test dialog 2020-12-15 00:55:40 -07:00
Eevee (Evelyn Woods)
07c9a83f75 Fix crash with teeth and Cerise 2020-12-15 00:48:28 -07:00
Eevee (Evelyn Woods)
bf81738e19 Partly implement helmet; get rover very close to correct (?) 2020-12-15 00:44:07 -07:00
Eevee (Evelyn Woods)
55abe8b53a Allow the bowling ball to slide normally 2020-12-15 00:26:00 -07:00
Eevee (Evelyn Woods)
f858668ca8 Clumsily teach bowling balls to destroy actors 2020-12-15 00:10:23 -07:00
Eevee (Evelyn Woods)
2d7df413ee Allow ghosts to pass through thin walls 2020-12-14 23:59:38 -07:00
Eevee (Evelyn Woods)
dae66de160 Thieves will, however, take bribes from anyone 2020-12-14 23:49:47 -07:00
Eevee (Evelyn Woods)
8b60a44b09 Fix loading of the initial entry direction for railroads 2020-12-14 23:39:07 -07:00
Eevee (Evelyn Woods)
2103c649f6 Implement a few quirks of ghost movement 2020-12-14 23:35:11 -07:00
Eevee (Evelyn Woods)
6d4326fe5b Thieves only steal from the player 2020-12-14 23:29:05 -07:00
Eevee (Evelyn Woods)
3e18e38f15 Add rough implementations of dynamite and bowling ball 2020-12-14 23:25:48 -07:00
Eevee (Evelyn Woods)
9ade84c6fe Generally only lose if the real player dies, not a doppelganger 2020-12-14 23:20:46 -07:00
Eevee (Evelyn Woods)
bf3c501353 Fix yellow tank behavior to be faux simultaneous 2020-12-14 23:14:31 -07:00
Eevee (Evelyn Woods)
a529414e42 Cycle an RFF after a failed override; move slide turnaround to move phase 2020-12-14 22:06:42 -07:00
Eevee (Evelyn Woods)
e4ab7dde86 Fix loading custom floors with junk in their modifier's high nybble 2020-12-14 22:06:01 -07:00
Eevee (Evelyn Woods)
a865647eae Non-players move instantly while sliding 2020-12-14 17:52:59 -07:00
Eevee (Evelyn Woods)
f02fa1a9bb Split up the wiring updates 2020-12-14 17:42:31 -07:00
Eevee (Evelyn Woods)
fea93aa9ec Fix wire tool only working in the upper-left quadrant 2020-12-14 17:32:48 -07:00
Eevee (Evelyn Woods)
00ac94ac8c Add a further hack atop the cooldown delay hack to fix adjacent trap release 2020-12-14 17:25:15 -07:00
Eevee (Evelyn Woods)
c34aaadf06 Attempt, unsuccessfully, to implement ice block melting 2020-12-14 17:07:35 -07:00
Eevee (Evelyn Woods)
efd25294ac Restore the notion of an out-of-turn move
This fixes a lot of replay sync issues with cloners; in CC2, actors
advance only one frame (1/3 tic) at a time, so when a cloned object
happens to get a turn later in the same tic that it was cloned, it only
ends up 1 frame ahead of everything else.  Since actors can only begin
moves on tic-aligned frames, even though it does get where it was going
sooner, it has to wait for a frame before moving, so the advantage
doesn't change anything.

The problem is that LL counts movement in tics, not frames, so that kind
of bonus turn puts the clone an entire tic ahead which can gum things
up.

This is still not perfect, but it's much closer.
2020-12-14 17:05:01 -07:00
Eevee (Evelyn Woods)
beb5a5c743 Change the debug mode trigger to a regular click 2020-12-14 17:02:14 -07:00
Eevee (Evelyn Woods)
f3f73a5e41 Move input handling into Level and clean it up a ton; add a bulk test gizmo 2020-12-14 17:02:14 -07:00
Eevee (Evelyn Woods)
189ab96e3c C2G strings can be empty 2020-12-13 23:56:13 -07:00
Eevee (Evelyn Woods)
16f11f3a9b Exits block CC1 blocks 2020-12-13 22:22:22 -07:00
Eevee (Evelyn Woods)
c8ed4b9fba Fix the spelling of Chuck Sommerville's name lol whoops 2020-12-13 20:43:01 -07:00
Eevee (Evelyn Woods)
2fa231a6cd Oops; include HTML/CSS changes for replays too 2020-12-13 20:39:55 -07:00
Eevee (Evelyn Woods)
1c9dee1213 Add support for recording replays, with a bunch of refactoring along the way 2020-12-13 20:36:12 -07:00
Eevee (Evelyn Woods)
85a81878cc Add a button to download a level from the editor 2020-12-13 16:23:45 -07:00
Eevee (Evelyn Woods)
df14b62b94 Auto-grow the map save buffer when necessary; do less slicing 2020-12-13 16:09:04 -07:00
Eevee (Evelyn Woods)
5e6784c235 Fix encoding and decoding of swivels 2020-12-13 15:56:24 -07:00
Eevee (Evelyn Woods)
0f5b8098f6 Split teleporting into its own pass, like Lynx 2020-12-13 02:23:14 -07:00
Eevee (Evelyn Woods)
93954135d2 Set splash delay back to 6; is correct after all I guess 2020-12-13 01:54:52 -07:00
Eevee (Evelyn Woods)
90008c3a89 Make the player push blocks at decision time
It turns out the player explores all their decisions in a very physical
way, which is the real source of block slapping and also means the
player can push blocks before anything else can move, regardless of
actor order.

This fixes at least half a dozen CC1 replays, which is just
mindboggling.
2020-12-13 00:39:36 -07:00
Eevee (Evelyn Woods)
bd4c04c1d8 Fix wiring order; relax player/monster collision 2020-12-12 21:00:28 -07:00
Eevee (Evelyn Woods)
f95913b6d8 Teeth chase the player's apparent position 2020-12-12 20:24:28 -07:00
Eevee (Evelyn Woods)
3a04b6276c Go back to toggling green objects instantly 2020-12-12 19:22:04 -07:00
Eevee (Evelyn Woods)
4d23acb27e Add on_approach; use it to erase animations and fix popdown floors 2020-12-12 19:00:58 -07:00
Eevee (Evelyn Woods)
077ac65650 Restore turn-based mode 2020-12-12 18:16:52 -07:00
Eevee (Evelyn Woods)
299b1578a7 Mostly revert actor loop reorg
I was right the first time, and I've proven it to myself now.  I
originally made the change because I couldn't see any other way to fix
the ICEBERG replay from Steam CC1, but now, I do!
2020-12-12 17:57:47 -07:00
Eevee (Evelyn Woods)
413fdce590 Rejigger input parsing to be stateless and better match CC2; syncs SCAVENGER HUNT! 2020-12-12 01:08:08 -07:00
Eevee (Evelyn Woods)
cfdbe0705a Add even faster playback options; fix some demo decoding bugs 2020-12-12 00:22:51 -07:00
Eevee (Evelyn Woods)
769d424dde Fix rendering breakage caused by a couple more out-of-turn moves 2020-12-11 22:54:01 -07:00
Eevee (Evelyn Woods)
8671bee08b Run wiring thrice per tic; recognize some tiles have odd propagation rules 2020-12-11 22:49:23 -07:00
Eevee (Evelyn Woods)
c17169f49d Rearranged debug panel a bit; added progress bar for replay playback 2020-12-11 22:15:39 -07:00
Eevee (Evelyn Woods)
fde7d9a11c Remove some old stuff; stub out replay progress bar 2020-12-11 21:40:40 -07:00
Eevee (Evelyn Woods)
fec09c03ba Support running the game at ludicrous speed 2020-12-11 21:31:07 -07:00
Eevee (Evelyn Woods)
0f0c7437a6 Allow rewinding a replay without desyncing it 2020-12-11 21:14:19 -07:00
Eevee (Evelyn Woods)
410af788fc Fix teleporters, and more generally out-of-turn movement 2020-12-11 20:58:50 -07:00
Eevee (Evelyn Woods)
a96c089d7f Move the debug panel to a sidebar 2020-12-11 19:43:22 -07:00
Eevee (Evelyn Woods)
b8dd0ae41e Add a delay before restarting a failed level by tapping the viewport 2020-12-11 14:39:14 -07:00
Eevee (Evelyn Woods)
75e981335f Restore the input viewer 2020-12-11 14:25:02 -07:00
Eevee (Evelyn Woods)
6c2afd7e32 Fix flicker when pushing a block across multiple cells 2020-12-11 13:59:23 -07:00
Eevee (Evelyn Woods)
2f130861d6 Remove animation_{progress,speed} and fix interpolation 2020-12-11 13:56:41 -07:00
Eevee (Evelyn Woods)
5572b3e692 Implement viewport size override debug setting 2020-12-10 18:53:23 -07:00
Eevee (Evelyn Woods)
b75253a249 Rearrange actor loop to put movement advancement at the end
I don't know why I ever thought this was a separate pass; I think it was
just the easiest way to make smooth scrolling work when I first
implemented it on like day 2.  Turns out it wasn't ever correct and has
all manner of subtle implications I'll be sorting out for ages.

This does make the turn-based stuff //way// simpler, though.
2020-12-10 18:51:40 -07:00
Eevee (Evelyn Woods)
831a9392e3 Fix debug icon URL in prod 2020-12-10 13:54:59 -07:00
Eevee (Evelyn Woods)
81f7e7fd64 Add encodings for the gift bow and circuit block 2020-12-10 13:51:15 -07:00
Eevee (Evelyn Woods)
823fe4de37 bestowal_bow => gift_bow, directional_block => frame_block 2020-12-10 13:28:40 -07:00
Eevee (Evelyn Woods)
bd4cc10b16 Implement foil 2020-12-10 12:48:01 -07:00
Eevee (Evelyn Woods)
ef78085e40 Ghosts turn left, not right! 2020-12-10 12:45:15 -07:00
Eevee (Evelyn Woods)
422c702777 Allow blocks to reverse on railroads; partially implement teleporter wiring 2020-12-10 12:25:49 -07:00
Eevee (Evelyn Woods)
6063ea9fba Wired tracks only switch on pulse; wired red teleports only work when powered 2020-12-09 21:07:14 -07:00
Eevee (Evelyn Woods)
db2a24319d Fix lightning bolt; implement speed boots 2020-12-09 20:52:58 -07:00
Eevee (Evelyn Woods)
b01d50c7e8 Move replay support into the debug panel 2020-12-09 20:36:13 -07:00
Eevee (Evelyn Woods)
b2ff27f241 New LL tile: circuit block, which overrides any wiring below it 2020-12-09 20:23:27 -07:00
Eevee (Evelyn Woods)
235cc79e8b Add LL-specific exit poses for Lexy and Cerise 2020-12-09 20:22:35 -07:00
Eevee (Evelyn Woods)
5f804dde14 Finish the tileset! 2020-12-09 20:13:25 -07:00
Eevee (Evelyn Woods)
e6d7876679 Fix directional blocks losing their arrows after being cloned 2020-12-08 19:00:50 -07:00
Eevee (Evelyn Woods)
a23afe3d90 Editor: Disallow erasing the floor (!) 2020-12-08 18:58:59 -07:00
Eevee (Evelyn Woods)
ed58cbac6b Editor: Add a (glitchy) wire tool; add right-click support to existing tools (hurrah) 2020-12-08 18:56:49 -07:00
Eevee (Evelyn Woods)
829184056e Fix activating debug mode before loading a level 2020-12-08 17:54:21 -07:00
Eevee (Evelyn Woods)
9d9c5a3988 Remove the debug checkboxes that don't actually work yet 2020-12-08 17:49:11 -07:00
Eevee (Evelyn Woods)
35bbac9c99 Change the debug trigger to something more accessible 2020-12-08 17:46:48 -07:00
Eevee (Evelyn Woods)
a44ed295a2 Fix ball animation 2020-12-08 17:23:27 -07:00
Eevee (Evelyn Woods)
90b583df44 In CC2 (and Lynx, for different reasons), clones are immediately nudged out of the cloner 2020-12-08 17:22:20 -07:00
Eevee (Evelyn Woods)
f521bd6d2d New debug options: disable interpolation, show actor bboxes 2020-12-08 16:40:35 -07:00
Eevee (Evelyn Woods)
4ee56fad01 Briefly reveal invisible walls, as in CC2 2020-12-08 12:48:25 -07:00
Eevee (Evelyn Woods)
9735ef93e9 Fix being unable to click the CC2 action buttons more than once 2020-12-08 11:24:59 -07:00
Eevee (Evelyn Woods)
6d39fd3831 Allow "rotating" a counter tile to alter its value 2020-12-07 22:07:20 -07:00
Eevee (Evelyn Woods)
35f040c8d7 Implement saving of logic gates and directional blocks 2020-12-07 21:15:18 -07:00
Eevee (Evelyn Woods)
2849260672 Add swivels and stopwatches to the palette 2020-12-07 12:50:03 -07:00
Eevee (Evelyn Woods)
f462ae3394 Add some more complex tiles to the palette; add ,/. shortcuts 2020-12-07 12:45:21 -07:00
Eevee (Evelyn Woods)
c4bb1f3df1 Editor: Add a dedicated level browser with previews, and a button to add a new level 2020-12-06 20:57:02 -07:00
Eevee (Evelyn Woods)
e754e483ec Update the pack name when changing it in the editor 2020-12-06 19:23:19 -07:00
Eevee (Evelyn Woods)
aeac5c285b Finally add and wire up most of the debug panel 2020-12-06 18:51:12 -07:00
Eevee (Evelyn Woods)
c1ba299e9f Fix collision for socket and exit 2020-12-06 17:01:47 -07:00
Eevee (Evelyn Woods)
90b6498ff9 Make wires and gray buttons affect swivel doors 2020-12-06 16:57:07 -07:00
Eevee (Evelyn Woods)
30261a106f Mostly implement rover 2020-12-06 16:51:24 -07:00
Eevee (Evelyn Woods)
8428572def Step in reverse order; allow pushing blocks on railroads; gliders ignore turtles 2020-12-06 16:29:07 -07:00
Eevee (Evelyn Woods)
4838bb189b Fix keys accumulating while paused 2020-12-06 16:23:43 -07:00
Eevee (Evelyn Woods)
54381370c0 Add timid teeth; move movement decisions onto tile types; improve doppelganger behavior 2020-12-06 16:16:04 -07:00
Eevee (Evelyn Woods)
d981a0a4be Fix some interactions that differ between players/doppelgangers; add Cerise doppelganger movement 2020-12-06 14:59:54 -07:00
Eevee (Evelyn Woods)
3b07c78518 Oops; commit the icon for the tracks tool 2020-12-06 14:03:59 -07:00
Eevee (Evelyn Woods)
076aa9133a Improve splash page slightly; add pack saving in editor 2020-12-06 14:03:36 -07:00
Eevee (Evelyn Woods)
70df85187f Toggle force floors when powered 2020-12-06 11:13:37 -07:00
Eevee (Evelyn Woods)
f7080a2697 Editor: Fix shift-pencilling a tile with properties 2020-12-03 22:51:07 -07:00
Eevee (Evelyn Woods)
cb62786470 Fix yellow teleport behavior (you pick up even if it itself is not blocked); play pickup sound 2020-12-03 22:43:24 -07:00
Eevee (Evelyn Woods)
caf4906176 Fix a crash when trying to drop from an empty inventory 2020-12-03 22:21:44 -07:00
Eevee (Evelyn Woods)
564d247657 Play the thief sound even if you only lost bonus points 2020-12-03 21:58:45 -07:00
Eevee (Evelyn Woods)
c4dbdcb650 Update splash commentary about the editor 2020-12-03 21:38:49 -07:00
Eevee (Evelyn Woods)
30062485ab Fix hint saving 2020-12-03 21:38:39 -07:00
Eevee (Evelyn Woods)
87ac6f94a3 Editor: More tiles; more metadata; save hints, more or less 2020-12-03 21:19:47 -07:00
Eevee (Evelyn Woods)
7a710ee5dc Fix editor canvas centering in Chrome 2020-12-03 21:19:33 -07:00
Eevee (Evelyn Woods)
700d3898ab Add a half-baked notion of perception level to tile rendering 2020-12-03 21:18:46 -07:00
Eevee (Evelyn Woods)
8505b132bb Fix some minor bugs introduced into turn-based mode 2020-12-03 21:17:50 -07:00
Eevee (Evelyn Woods)
dfb274e85d Tileset: Partial Cerise walk; foil, hook; colorblind blue/green walls; touched up thief 2020-12-03 21:16:53 -07:00
Eevee (Evelyn Woods)
b97aaa81a9 Slime doesn't kill blobs; implement bribe; fix player size on level restart 2020-12-03 20:52:43 -07:00
Eevee (Evelyn Woods)
0cd1ea342d Implement yellow teleport pickup behavior 2020-12-03 20:52:00 -07:00
Eevee (Evelyn Woods)
3c43b8d7cd Respect a level's intended viewport size 2020-12-03 17:49:02 -07:00
Eevee (Evelyn Woods)
0885bfc9d2 Make hook load, canopy work, and force floors reverse with gray button 2020-12-03 17:48:24 -07:00
Eevee (Evelyn Woods)
411005eaa6 Editor: Stub out support for actually saving levels 2020-12-03 15:40:44 -07:00
Eevee (Evelyn Woods)
89ae9aa4a3 Fix C2M compression occasionally losing the last byte 2020-12-03 15:39:11 -07:00
Eevee (Evelyn Woods)
fa18238e60 Editor: Fix bad rendering when clicking a tool 2020-12-02 17:30:33 -07:00
Eevee (Evelyn Woods)
c1452e005f Disable action buttons when appropriate; add inv overflow; partial CC1 support 2020-12-02 17:23:55 -07:00
Eevee (Evelyn Woods)
e51665b612 Add basic support for drop/cycle/swap 2020-12-02 17:18:54 -07:00
Eevee (Evelyn Woods)
8d197ce479 Add a basic implementation of doppelgangers 2020-12-02 15:03:13 -07:00
Eevee (Evelyn Woods)
f0680ce0c4 Cleaned up several tile properties; added railroad adjusting 2020-12-02 13:54:32 -07:00
Eevee (Evelyn Woods)
72cba627a8 Add a prototype railroad track tool 2020-12-02 12:23:09 -07:00
Eevee (Evelyn Woods)
aa41336b90 Add more tiles and more kinds of adjustment 2020-12-02 11:54:15 -07:00
Eevee (Evelyn Woods)
ec5d9f7b12 Editor: Add selected tile, tool help, and hint editing; clean up toolbar style 2020-12-02 11:34:46 -07:00
Eevee (Evelyn Woods)
0d376e003e Editor: Teach the adjust tool to edit individual tiles 2020-12-02 09:05:20 -07:00
Eevee (Evelyn Woods)
560a89cfd3 Fix using the wrong tile for the railroad switch 2020-11-30 09:09:21 -07:00
Eevee (Evelyn Woods)
e7c9bbe846 Implement most of the railroad behavior 2020-11-30 09:08:55 -07:00
Eevee (Evelyn Woods)
8d26de6915 Implement railroad rendering 2020-11-28 12:56:08 -07:00
Eevee (Evelyn Woods)
14061dec0e Fix the editor's viewport size to match the level 2020-11-28 12:36:35 -07:00
Eevee (Evelyn Woods)
4218657c28 Use consistent drawing behavior in the editor; add shift, ctrl pencil modifiers 2020-11-28 11:59:57 -07:00
Eevee (Evelyn Woods)
dff3081194 Editor: center/pad the level, fix some bugs with pencil drawing 2020-11-28 11:07:02 -07:00
Eevee (Evelyn Woods)
349af15e05 Fix being stuck in rewind mode forever when using the button 2020-11-25 03:59:57 -07:00
Eevee (Evelyn Woods)
50c81c5c96 Add directed blob + walker sprites and a custom slime splash (btw slime now destroys stuff) 2020-11-25 03:37:58 -07:00
Eevee (Evelyn Woods)
3a454d77f5 Implement the remaining logic gates and /most/ of their rendering! 2020-11-25 03:14:06 -07:00
Eevee (Evelyn Woods)
ac6e33bb6c Simplify blitting; fix arrow blitting; impl light switch; load more items 2020-11-25 01:14:15 -07:00
Eevee (Evelyn Woods)
6c6ce8f344 Fix fireballs being blocked by fire 2020-11-24 23:13:25 -07:00
Eevee (Evelyn Woods)
4b6a8e49ae Fix errors when drawing outside the level in the editor 2020-11-24 01:31:09 -07:00
Eevee (Evelyn Woods)
5cb29c8f7d Overhaul collision
Collision now uses bits and masks.  The main upshot is that ghost and
ice/directional blocks collide much more correctly, now.  And turtles
block fireballs.

Also, monsters can now move over "no" signs, and can trample the player
if she's standing on top of an item.

While I was at it, I finished implementing the "bestowal bow", an item
mod (same layer as the "no" sign) that allows any actor to pick up the
item in that tile.
2020-11-23 23:41:32 -07:00
Eevee (Evelyn Woods)
fb301b3b3e Initialize direction when placing actor tiles in the editor 2020-11-23 22:18:40 -07:00
Eevee (Evelyn Woods)
ca4eaa86cb Fix some minor aesthetic issues with the editor 2020-11-23 21:54:53 -07:00
Eevee (Evelyn Woods)
39d463932b Remove the "stuck" flag and fix all the repercussions of that 2020-11-23 21:35:28 -07:00
Eevee (Evelyn Woods)
e803af2fd2 Handle actors starting on force floors; partially implement item bestowal 2020-11-23 19:22:59 -07:00
Eevee (Evelyn Woods)
3514f25f2b Revert part of the hearts/time style improvements to prevent grid blowout from hints 2020-11-03 14:40:05 -07:00
Eevee (Evelyn Woods)
6804169a8a Spruce up buttons a teeny bit more 2020-11-03 14:07:49 -07:00
Eevee (Evelyn Woods)
37072fa003 Add three new music tracks from notchris 2020-11-03 14:07:25 -07:00
Eevee (Evelyn Woods)
c8343f1a23 Replace keys/doors with colorblind friendly versions that heavily use shapes 2020-11-03 13:53:40 -07:00
Eevee (Evelyn Woods)
81c7f97d72 Improve behavior on mobile
- Hide the key hints in portrait mode

- Make auto-scaling more robust; it now handles when the player root is
  wider than the actual play area, it better understands the inventory
  behavior in portrait mode, and it recognizes when it needs to shrink;
  with these changes, the game actually fills the screen on both Firefox
  and Chrome on my phone!

- Replace the text buttons with SVG icons

- Add a little more contrast to button edges

- Fix alignment of the heart/time/score counters in portrait mode

- Detect movement based on where the touch is relative to the level
  viewport, not the entire play area (oof)
2020-11-03 13:50:34 -07:00
Eevee (Evelyn Woods)
1b6bd68879 Bump undo buffer size to 30 seconds 2020-11-03 12:09:12 -07:00
Eevee (Evelyn Woods)
8ff0bd803a Use a ring buffer for undo; don't pause when running out of undo during rewind 2020-11-03 11:57:16 -07:00
Eevee (Evelyn Woods)
350ac08d4d Shrink size of undo buffer by 40%
Using simple maps of changed properties, rather than a big pile of
closures, takes up significantly less space.
2020-11-03 11:48:51 -07:00
Eevee (Evelyn Woods)
84840d2b02 Consider turn-based mode to be aid; switch back to realtime correctly 2020-11-03 10:32:25 -07:00
Eevee (Evelyn Woods)
e7e02281a2 Clean up turn-based code
Mostly style nits, but also:

- Renamed some stuff in anticipation of removing GameEnded.

- Actor decisions are independent, so there's no need to do most of them
  in the first part of a tic and the player in the second part; they can
  all happen together in the second part.

- waiting_for_input was merged into turn_based, which I think makes it
  easier to follow what's going on between tics.  Although I just
  realized it introduces a bug, so, better fix that next.

- The canvas didn't need to know if we were waiting or not if we just
  force the tic offset to 1 while waiting.  This also fixed some slight
  jitter with force floors.
2020-11-03 09:50:37 -07:00
Eevee (Evelyn Woods)
83a1dd23ff Merge branch 'master' of github.com:eevee/lexys-labyrinth 2020-11-02 15:40:09 -07:00
Eevee
bf74530aa2
Merge pull request #3 from Patashu/master
Implement Turn-Based Mode
2020-11-02 15:39:06 -07:00
Eevee (Evelyn Woods)
131f06ee84 Update tileset with a few more CC2 tiles: slime, transmogrifier, better colorblind keys, other smaller improvements 2020-11-01 11:53:19 -07:00
Eevee (Evelyn Woods)
49ff0d9723 Clean up wiring drawing and logic (zero gates is now a no-op!); begin implementing logic gates 2020-11-01 11:36:17 -07:00
Eevee (Evelyn Woods)
37b44bcca4 Stub out debug controls 2020-11-01 11:29:48 -07:00
Eevee (Evelyn Woods)
060dfdc7d7 Implement drawing and correctly pushing directional blocks 2020-10-28 00:17:41 -06:00
Timothy Stiles
f670224460 forgot to change a continue to a return 2020-10-26 16:08:29 +11:00
Timothy Stiles
32b4399683 Merge remote-tracking branch 'upstream/master' 2020-10-26 16:05:34 +11:00
Eevee (Evelyn Woods)
dedaa45d07 Fix pushing sliding blocks 2020-10-24 22:30:18 -06:00
Eevee (Evelyn Woods)
368e4676fd Mostly implement orange buttons and flame jets 2020-10-24 21:33:11 -06:00
Timothy Stiles
509b3ca3b7 Merge remote-tracking branch 'upstream/master' 2020-10-25 14:31:32 +11:00
Eevee (Evelyn Woods)
0c9a7e3d07 Implement all three blob modes and fix up some minor details
This makes the replays from the Steam copies of Blobnet and Nice Day
play back correctly!  Neato!
2020-10-24 20:49:14 -06:00
Eevee (Evelyn Woods)
a07e10218e Monsters always attempt their last candidate direction, even if blocked 2020-10-24 20:32:22 -06:00
Eevee (Evelyn Woods)
f1b040f176 Implement green teleports and the Lynx/CC2 PRNG 2020-10-23 21:09:31 -06:00
Eevee (Evelyn Woods)
603a74a751 Show a hint the player starts on 2020-10-23 17:44:26 -06:00
Eevee (Evelyn Woods)
2820c067c5 Implement the CC2 "no sign" 2020-10-23 17:37:50 -06:00
Timothy Stiles
a0e37422b9 Merge remote-tracking branch 'upstream/master' 2020-10-23 20:26:39 +11:00
Eevee (Evelyn Woods)
8c2f71294f Fix crash caused by last fix dammit (fixes #9) 2020-10-22 14:44:05 -06:00
Timothy Stiles
fedbd200fc Merge remote-tracking branch 'upstream/master' 2020-10-22 18:04:43 +11:00
Eevee (Evelyn Woods)
d03d61516f Fix NaNs sneaking into the save file 2020-10-21 23:34:59 -06:00
Eevee (Evelyn Woods)
09eb03dad6 Iterate actors in reverse order to match Lynx/CC2 (fixes #7) 2020-10-21 21:24:59 -06:00
Eevee (Evelyn Woods)
8073604271 Add... error handling... sort of... for levels at least 2020-10-21 21:07:50 -06:00
Eevee (Evelyn Woods)
edbe32c148 Add support for drag/drop, dir upload, C2G, AND lazy level loading! 2020-10-21 20:47:07 -06:00
Timothy Stiles
9e53aa75a0 I tried 'snappier' and it was a bad idea, so now you don't have to
it basically skips the first frame of each turn-based movement. it's not TERRIBLE but the smooth movement is already really nice, we don't need to change it
2020-10-14 23:13:38 +11:00
Timothy Stiles
4388402850 Merge remote-tracking branch 'upstream/master' 2020-10-14 22:43:35 +11:00
Timothy Stiles
e3de4d59c7 spaceify 2020-10-14 22:34:00 +11:00
Timothy Stiles
f7e83342a0 fix a turn based rewinding visual bug 2020-10-14 22:33:45 +11:00
Timothy Stiles
be5cc7f97f spaceify 2020-10-14 22:23:13 +11:00
Timothy Stiles
e9d542f438 fix a bug when undoing while waiting for input
need to unset it (we used to do this but it got lost in the refactor)
2020-10-14 22:22:27 +11:00
Timothy Stiles
a7c38ae0af fix 'keys held for less than a frame are ignored' bug
this was annoying me!
2020-10-14 22:17:07 +11:00
Timothy Stiles
bb168d7e1e fix force arrows in turn based mode
if you got misaligned in tic_counter then you'd force arrow forever.
also, turn_based is no longer passed in! yay!
2020-10-14 22:07:29 +11:00
Timothy Stiles
e53f00a432 I broke force arrows, in fact!
so much for tooting my own horn
2020-10-14 21:54:13 +11:00
Timothy Stiles
a8ce3bca11 fix bugs
we're back at parity now, it looks like
2020-10-14 21:42:51 +11:00
Timothy Stiles
2e1a87199a code refactor part 1: advance_tic is now two parts
seems to work so far
2020-10-14 21:24:46 +11:00
Eevee (Evelyn Woods)
36b9f2efd7 Make (most) actors pick up blue keys 2020-10-07 15:28:51 -06:00
Eevee (Evelyn Woods)
197113c842 Add two new tracks from jneen 2020-10-07 15:16:20 -06:00
Eevee (Evelyn Woods)
b20e2cac8c Tanks in traps turn around 2020-10-04 09:52:59 -06:00
Eevee (Evelyn Woods)
5c2fc32546 Tanks always decide to move in their given direction, even when blocked 2020-10-04 09:47:03 -06:00
Eevee (Evelyn Woods)
7f8efaa4e0 Fix flicker when moving in sync with a N/W actor at the edge of the viewport 2020-10-04 09:40:52 -06:00
Eevee (Evelyn Woods)
8adb630862 Add partial wiring support 2020-10-01 06:46:07 -06:00
Eevee (Evelyn Woods)
4cd0585d0b Revert the blue wall compat fix and replace them with popwalls instead (fixes #5) 2020-10-01 03:22:49 -06:00
Eevee (Evelyn Woods)
4f5d169d06 Reduce SFX volume in levels with a lot of off-screen button presses 2020-10-01 03:08:54 -06:00
Eevee (Evelyn Woods)
9b873764fb Shim around several compat issues that affect CCLP levels
- CCLP1 #81 requires pushing blocks off of blue walls, which is
  impossible in CC2 but allowed in TW Lynx (unclear if this is a lynx
  behavior or a tw bug)

- CCLP1 #89 has a tank start on a recessed wall and drive off of it,
  expecting the recessed wall to be left alone, but under CC2 rules it
  becomes a wall; such walls are now automatically converted to a new
  tile, the "doubly recessed wall", which restores the expected behavior
  without changing how recessed walls work in general

- CCLP4 #135 expects pressing a blue button to not affect blue tanks
  that are currently in mid-slide

In addition, the behavior of blue buttons now matches the Lynx/Steam
behavior: the press is stored as a flag and queued until the tank is
next able to move.
2020-10-01 03:08:25 -06:00
Eevee (Evelyn Woods)
8326b42bc7 Pad "real time" seconds to two places; allow tied scores without aid to replace scores with aid 2020-09-30 02:37:43 -06:00
Eevee (Evelyn Woods)
f99b9826be Detect trap buttons that are held down when the level begins 2020-09-30 02:20:33 -06:00
Eevee (Evelyn Woods)
f4363b8fda Rewrite how connections work
- Teleporters now connect on the fly, rather than having fixed
  connections (important because dynamite can destroy teleporters!)

- If custom connections are present, red and brown buttons ONLY use
  those, rather than falling back to CC2 connection rules

- Multiple brown buttons connected to the same trap should now work
  correctly
2020-09-30 02:11:17 -06:00
Eevee (Evelyn Woods)
6df0c96d1b Make the green key and lock more colorblind-friendly 2020-09-29 10:58:37 -06:00
Eevee (Evelyn Woods)
db3af69ff7 Make gray buttons undoable 2020-09-28 20:44:46 -06:00
Eevee (Evelyn Woods)
dfa0fa4a9e Add gray buttons to the C2M implementation 2020-09-28 20:43:22 -06:00
Eevee (Evelyn Woods)
325a06395d Partly implement gray buttons; expose them and green stuff in the editor 2020-09-28 04:09:24 -06:00
Eevee (Evelyn Woods)
76051870b7 Basically finish the camera region editing tool; add save/load support for it 2020-09-28 04:00:55 -06:00
Eevee (Evelyn Woods)
432bb881e6 Split out mouse operations; add camera regions, our first custom feature 2020-09-28 02:58:11 -06:00
Eevee (Evelyn Woods)
8711d87a36 Split the editor into its own module 2020-09-28 00:58:31 -06:00
Eevee (Evelyn Woods)
ade135514b Undo actor animation (fixes #4) 2020-09-28 00:38:24 -06:00
Eevee (Evelyn Woods)
684021f93d Fix typo in drowned player tile 2020-09-27 04:03:15 -06:00
Eevee (Evelyn Woods)
78bb1f2dc1 Populate player 2 state sprites so she can actually be drawn 2020-09-26 20:38:50 -06:00
Eevee (Evelyn Woods)
70d6739465 Ice blocks splash in water 2020-09-26 20:21:00 -06:00
Eevee (Evelyn Woods)
760ca374ce Fall back to webkitAudioContext if necessary, sigh 2020-09-26 19:47:51 -06:00
Timothy Stiles
c8d80dfc63 another spaceify (oops)
I literally tried to change the setting for this in notepad++ but it crashes every time I do

ha ha
2020-09-26 23:48:47 +10:00
Timothy Stiles
ccfd5c30ce fix animation ugliness in turn-based mode
Uguhughugh it looks SO SMOOTH NOW. I can go to bed happy.
2020-09-26 23:45:53 +10:00
Timothy Stiles
e908434a20 only restart on fresh press of spacebar 2020-09-26 23:01:42 +10:00
Timothy Stiles
5b7273e9d9 Turn-Based: add space to wait 2020-09-26 22:40:38 +10:00
Timothy Stiles
e6a4e88935 spaceify previous commit 2020-09-26 22:32:01 +10:00
Timothy Stiles
d2e900dc3a fix a rewind bug in non turn based mode
Was caused by not making these setters undoable.
2020-09-26 22:31:18 +10:00
Timothy Stiles
30a145599c fix a graphical undoing bug in Turn-Based Mode
turns out we were smuggling pending_undo to previous moves. aha!
2020-09-26 22:18:19 +10:00
Timothy Stiles
5c6cd01b39 Implement turn based mode
Seems to work mechanically though I haven't extensively stress tested it yet. Force floors work the way you'd want them to though (you're given control whenever you can make an input and not otherwise).
There are some graphical bugs with rewinding, but there were some without turn based mode anyway...
2020-09-26 22:10:42 +10:00
Eevee (Evelyn Woods)
4a0ba44116 Restore floors under thin walls in CCL maps 2020-09-26 04:49:58 -06:00
Eevee (Evelyn Woods)
967520c1bd Change fake floors to not be blocking; causes bad visual/audio feedback 2020-09-26 03:15:01 -06:00
Eevee (Evelyn Woods)
b40805c02e Take a rough swing at phone support 2020-09-26 02:55:39 -06:00
Eevee (Evelyn Woods)
a2e1ed4820 Remove unused sound effect 2020-09-26 02:55:23 -06:00
Eevee (Evelyn Woods)
fb71dd1ff1 Fix conundrum, which I accidentally corrupted a bit 2020-09-26 01:44:06 -06:00
Eevee (Evelyn Woods)
12066072ec Disable the big ol cheat whoops 2020-09-26 00:17:15 -06:00
142 changed files with 28807 additions and 5105 deletions

View File

@ -1,8 +1,16 @@
# Lexy's Labyrinth
This is a web implementation of a puzzle game that bears a _striking_ similarity to [Chip's Challenge](https://wiki.bitbusters.club/Chip%27s_Challenge) and its [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2), but is legally distinct, and also free!
This is a reimplementation of [**Chip's Challenge®**](https://wiki.bitbusters.club/Chip%27s_Challenge), that puzzle game you might remember from the early 90s (and its long-awaited [sequel](https://wiki.bitbusters.club/Chip%27s_Challenge_2)).
It is a work in progress and also might be abandoned and forgotten at any time.
It's free; runs in a browser; has completely new artwork, sounds, and music; comes with hundreds of quality fan-made levels built in; and can load the original levels from a copy of the commercial game!
Documentation is underway on the [wiki](https://github.com/eevee/lexys-labyrinth/wiki).
## My lawyer is telling me to say this
To be absolutely clear: this is a ***fan project*** and is not affiliated with, sponsored by, endorsed by, or in any way approved of by Bridgestone Multimedia Group LLC. **Chip's Challenge** is a registered trademark of Bridgestone Multimedia Group LLC, and is used here for identification purposes only.
Despite the names, the built-in "Chip's Challenge Level Packs" are community creations and have no relation to the commercial games or their levels.
## Play online
@ -10,37 +18,41 @@ Give it a try, I guess! [https://c.eev.ee/lexys-labyrinth/](https://c.eev.ee/le
## Current status
- Supports 99% of Chip's Challenge 1
- Supports 75% of Chip's Challenge 2
- Fully compatible with Chip's Challenge 1 levels... barring a few obscure rule changes
- Fully compatible with Chip's Challenge 2 levels... barring a few obscure bugs
- Completely original tileset, sound effects, and music
- Can load MS Chip's Challenge DAT/CCL files and Steam Chip's Challenge C2M files
- Can load levels from your hard drive
- Can play back replays (demos) from C2M files, though some may desync
- Allows undoing moves, with moderate success
- Has the beginning bits of a level editor
- Compatible with MS Chip's Challenge DAT/CCL files, Steam Chip's Challenge C2G/C2M files, and ZIP files
- Can load one of its built-in level packs, the original levels, or anything you've got lying around
- Able to record and play back demos (replays) from Steam-format levels
- Lets you rewind your mistakes, up to 30 seconds back
- Lets you take the pressure off by switching from real-time to turn-based mode, where nothing moves until you do
- Contains a completely usable level editor with support for every tile in Chip's Challenge 2
- Works on touchscreens too
- Has compatibility settings for opting into behavior (or bugs) from particular implementations
- Debug mode (click the logo in the lower left)
### Planned features
- Save your score, and compare it to the BBC leaderboards
- Load levels directly from the BBC set list
- Support for all of the nonsense in Chip's Challenge 2
- Allow playing the original commercial levels by dragging the data files in from your own computer
- Support various sets of bugs from various implementations
- Play the game turn-based instead of realtime (i.e., nothing moves until Chip does)
- Record demos
- Mouse and touchscreen support
- Bunches of debug features
- Outright cheat in a variety of ways
- Mouse support
### Noble aspirations
## For developers
- New exclusive puzzle elements?? Embrace extend extinguish baby
It's all static JS; there's no build system. If you want to run it locally, just throw your favorite HTTP server at a checkout and open a browser. (Browsers won't allow XHR from `file:///` URLs, alas. If you don't have a favorite HTTP server, try `python -m http.server`.)
If you have Node installed, you can test the solutions included with the bundled level packs without needing a web browser:
```
node js/headless/bulktest.mjs
```
Note that solution playback is still not perfect, so don't be alarmed if you don't get 100% — only if you make a change and something regresses.
## Special thanks
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord
- The incredible nerds who put together the [Chip Wiki](https://wiki.bitbusters.club/) and also reside on the Bit Busters Discord, including:
- ruben for documenting the CC2 PRNG
- The Architect for documenting the CC2 C2G parser
- Everyone who worked on [Chip's Challenge Level Pack 1](https://wiki.bitbusters.club/Chip%27s_Challenge_Level_Pack_1), the default set of levels
- [Tile World](https://wiki.bitbusters.club/Tile_World) for being an incredible reference on Lynx mechanics
- Everyone who contributed music — see [`js/soundtrack.js`](js/soundtrack.js) for a list!
Not associated with or blessed by Chuck Somerville, Niffler, or AOP.

BIN
ending.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
icon-debug.png Normal file

Binary file not shown.

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

BIN
icons/compat-custom.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

BIN
icons/compat-lexy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 B

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 B

After

Width:  |  Height:  |  Size: 711 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 681 B

BIN
icons/compat-steam.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 670 B

BIN
icons/editor-icons.aseprite Normal file

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

BIN
icons/rotate-left.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 478 B

BIN
icons/rotate-right.png Normal file

Binary file not shown.

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

BIN
icons/tool-camera.png Normal file

Binary file not shown.

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

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

Binary file not shown.

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

BIN
icons/tool-tracks.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 488 B

View File

@ -5,201 +5,450 @@
<title>Lexy's Labyrinth</title>
<link rel="stylesheet" type="text/css" href="style.css">
<link rel="shortcut icon" type="image/png" href="icon.png">
<script>
"use strict";
{
let domloaded = false;
window.addEventListener('DOMContentLoaded', ev => domloaded = true);
let _ll_log_fatal_error = (err, ev) => {
document.getElementById('loading').setAttribute('hidden', '');
let failed = document.getElementById('failed');
failed.removeAttribute('hidden');
document.body.setAttribute('data-mode', 'failed');
failed.classList.add('--got-error');
let stack = '(origin unknown)';
if (err.stack && err.stack.match(/\n/)) {
// Chrome sometimes gives us a stack that's actually just the message without
// any filenames, in which case skip it
stack = err.stack.replace(/^/mg, " ");
}
else if (err.fileName) {
stack = `in ${err.fileName} at ${err.lineNumber}:${err.columnNumber}`;
}
else if (ev) {
stack = `in ${ev.filename} at ${ev.lineno}:${ev.colno}`;
}
failed.querySelector('pre').textContent = `${err.toString()}\n\n${stack}`;
};
window.ll_log_fatal_error = function(err, ev) {
if (domloaded) {
_ll_log_fatal_error(err, ev);
}
else {
window.addEventListener('DOMContentLoaded', () => _ll_log_fatal_error(err, ev));
}
};
let error_listener = ev => {
if (! ev.error)
// Not a script error
return;
try {
ll_log_fatal_error(ev.error, ev);
}
catch (err) {}
};
window.addEventListener('error', error_listener, true);
// Once we've loaded successfully, drop the handler
window.ll_successfully_loaded = function() {
window.removeEventListener('error', error_listener, true);
};
}
</script>
<!-- FIXME it would be super swell if i could load this lazily -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@latest/Sortable.min.js"></script>
<script type="module" src="js/main.js"></script>
<meta name="og:type" content="website">
<meta name="og:image" content="https://c.eev.ee/lexys-labyrinth/og-preview.png">
<meta name="og:title" content="Lexy's Labyrinth">
<meta name="og:description" content="A (work in progress) reimplementation of Chip's Challenge 1 and 2, using entirely free assets.">
<meta name="og:description" content="Free online puzzle game that emulates Chip's Challenge. Play hundreds of community curated levels, load the levels from the commercial games, or make your own with the built-in editor.">
<meta name="description" content="Free online puzzle game that emulates Chip's Challenge. Play hundreds of community curated levels, load the levels from the commercial games, or make your own with the built-in editor.">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
</head>
<body data-mode="splash">
<body data-mode="failed">
<script>document.body.setAttribute('data-mode', 'loading');</script>
<svg id="svg-iconsheet">
<defs>
<g id="svg-icon-menu-chevron">
<path d="M2,4 l6,6 l6,-6 v3 l-6,6 l-6,-6 z"></path>
</g>
<g id="svg-icon-prev">
<path d="M14,1 2,8 14,14 z">
</g>
<g id="svg-icon-next">
<path d="M2,1 14,8 2,14 z">
</g>
<!-- Actions -->
<g id="svg-icon-up">
<path d="M0,12 l8,-8 l8,8 z"></path>
</g>
<g id="svg-icon-right">
<use href="#svg-icon-up" transform="rotate(90 8 8)"></use>
</g>
<g id="svg-icon-down">
<use href="#svg-icon-up" transform="rotate(180 8 8)"></use>
</g>
<g id="svg-icon-left">
<use href="#svg-icon-up" transform="rotate(270 8 8)"></use>
</g>
<g id="svg-icon-drop">
<path d="M6,0 h4 v9 h3 l-5,5 h7 v2 h-14 v-2 h7 l-5,-5 h3"></path>
</g>
<g id="svg-icon-cycle">
<path d="M2,3 H11 V1 l4,4 -4,4 V7 H2 Z"></path>
<path d="M14,9 H5 V7 l-4,4 4,4 v-2 h9 z"></path>
</g>
<g id="svg-icon-swap">
<path d="m 7,1 h 2 l 1,1 V 6 L 9,7 v 4 L 8,11.5 7,11 V 7 L 6,6 V 2 Z"></path>
<path d="M 8,13 13,11 8,9 3,11 Z m 0,2 7,-3 V 11 L 8,8 1,11 v 1 z"></path>
<ellipse cx="5.5" cy="11" rx="0.75" ry="0.5"></ellipse>
</g>
<!-- Hint background -->
<g id="svg-icon-hint">
<path d="M1,8 a7,7 0 1,1 14,0 7,7 0 1,1 -14,0 M2,8 a6,6 0 1,0 12,0 6,6 0 1,0 -12,0"></path>
<path d="M5,6 a1,1 0 0,0 2,0 a1,1 0 1,1 1,1 a1,1 0 0,0 -1,1 v1 a1,1 0 1,0 2,0 v-0.17 A3,3 0 1,0 5,6"></path>
<circle cx="8" cy="12" r="1"></circle>
</g>
<!-- Editor stuff -->
<g id="svg-icon-zoom">
<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">
<img src="icon.png" alt="">
<h1>Lexy's Labyrinth</h1>
<p>— a game by <a href="https://eev.ee/">eevee</a></p>
<img id="header-icon" src="icon.png" alt="">
<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-about" type="button">about</button>
<button id="main-help" type="button" disabled>help</button>
<button id="main-options" type="button" disabled>options</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>
<header id="header-pack">
<h2 id="level-pack-name">Chip's Challenge Level Pack 1</h2>
<nav>
<button id="main-test-pack" type="button">Bulk test</button>
<button id="main-change-pack" type="button">Change pack</button>
<button id="player-edit" type="button">Edit</button>
<button id="editor-play" type="button">Test</button>
<button id="editor-play" type="button">Play</button>
</nav>
</header>
<header id="header-level">
<h3 id="level-name">Level 1 — Key Pyramid</h3>
<nav>
<button id="main-prev-level" type="button"></button>
<button id="main-prev-level" type="button">
<svg class="svg-icon" viewBox="0 0 16 16" title="previous"><use href="#svg-icon-prev"></svg>
</button>
<button id="main-choose-level" type="button">Level select</button>
<button id="main-next-level" type="button"></button>
<button id="main-next-level" type="button">
<svg class="svg-icon" viewBox="0 0 16 16" title="next"><use href="#svg-icon-next"></svg>
</button>
</nav>
</header>
<main id="splash">
<main id="failed">
<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>
<main id="loading" hidden>
<p>...loading...</p>
<div class="scrolling-sidewalk">
<img src="loading.gif" alt="Lexy walking">
</div>
</main>
<script>
document.querySelector('#failed').setAttribute('hidden', '');
document.querySelector('#loading').removeAttribute('hidden');
</script>
<main id="splash" hidden>
<div class="drag-overlay"></div>
<header>
<h1><img src="og-preview.png" alt="">Lexy's Labyrinth</h1>
<img src="og-preview.png" alt="">
<h1>Lexy's Labyrinth</h1>
<p>an unofficial <strong>Chip's Challenge</strong>® emulator</p>
<button id="splash-fullscreen" type="button" title="Toggle fullscreen">
<svg class="svg-icon" viewBox="0 0 16 16">
<path d="m 11,1 h 4 V 5 L 14,4 12,6 10,4 12,2 Z"></path>
<path d="m 11,15 h 4 v -4 l -1,1 -2,-2 -2,2 2,2 z"></path>
<path d="M 5,1 H 1 V 5 L 2,4 4,6 6,4 4,2 Z"></path>
<path d="M 5,15 H 1 v -4 l 1,1 2,-2 2,2 -2,2 z"></path>
</svg>
</button>
</header>
<section id="splash-intro">
<p><strong>Welcome</strong> to Lexy's Labyrinth, an open source puzzle game that is curiously similar to — but legally distinct from — the Atari classic <a href="https://en.wikipedia.org/wiki/Chip%27s_Challenge">Chip's Challenge</a>!</p>
<p>(This is a Chip's Challenge <em>emulator</em>, designed to be an accessible way to play community-made levels with free assets. It's 99% compatible with Chip's Challenge 1, and support for Chip's Challenge 2 is underway. But you can safely ignore all that and treat this as its own game.)</p>
<p>Please note that <em>levels themselves</em> may contain hints or lore referring to a guy named Chip collecting computer chips, even though you are clearly a fox named Lexy collecting hearts. Weird, right? Sorry for any confusion!</p>
<p>Pick a level pack to get started! You can also get more technical details or report bugs on <a href="https://github.com/eevee/lexys-labyrinth">GitHub</a>, find out more about Chip's Challenge via the <a href="https://bitbusters.club/">Bit Busters Club</a> fansite, or support this endeavor (and other things I do) via <a href="https://www.patreon.com/eevee">Patreon</a>!</p>
<!-- TODO i want to make clear this is a chip's challenge emulator without bogging people down too much about what that means -->
</section>
<div id="splash-links">
<a href="https://github.com/eevee/lexys-labyrinth/wiki">About</a>
<a href="https://github.com/eevee/lexys-labyrinth/wiki/How-To-Play">How to play</a>
<a href="https://github.com/eevee/lexys-labyrinth">Source code and more</a>
<a href="https://patreon.com/eevee">Support on Patreon</a>
</div>
<p id="splash-disclaimer"><strong>Chip's Challenge</strong> is a registered trademark of Bridgestone Media Group LLC, used here for identification purposes only. Not affiliated with, sponsored, or endorsed by Bridgestone Media Group LLC.</p>
<section id="splash-stock-levels">
<h2>Just play something</h2>
<!-- populated by js -->
</section>
<h2>Play</h2>
<ul class="played-pack-list" id="splash-stock-pack-list">
<!-- populated by js -->
</ul>
<div class="button-row">
<button type="button" class="button-big" disabled>More levels</button>
<button type="button" class="button-big" disabled>Other saved scores</button>
</div>
<section id="splash-upload-levels">
<h2>Other levels</h2>
<p>You can play <code>CHIPS.DAT</code> from the original Microsoft version, any custom levels you have lying around, or perhaps ones you found on the <a href="https://sets.bitbusters.club/">Bit Busters Club set list</a>!</p>
<!-- TODO explain how to find chips.dat or steam folder -->
<!-- TODO drag and drop? -->
<input id="splash-upload" type="file" accept=".dat,.ccl,.c2m,.ccs">
<button type="button" id="splash-upload-button" class="button-big">Open a local level<!-- TODO: <br>(or drag and drop a file into this window) --></button>
<p>Supports both the old Microsoft <code>CHIPS.DAT</code> format and the Steam <code>C2M</code> format.</p>
<p>Does <em>not</em> yet support the Steam <code>C2G</code> format, so tragically, the original Steam levels can only be played one at a time. This should be fixed soon!</p>
<!--
<p>If you own the Steam versions of <a href="https://store.steampowered.com/app/346850/Chips_Challenge_1/">Chip's Challenge 1</a> (<em>free!</em>) or <a href="https://store.steampowered.com/app/348300/Chips_Challenge_2/">Chip's Challenge 2</a> ($5 last I checked), you can play those too, even on Linux or Mac:</p>
<ol class="normal-list">
<li>Right-click the game in Steam and choose <em>Properties</em>. On the <em>Local Files</em> tab, click <em>Browse local files</em>.</li>
<li>Open the <code>data</code> folder, then <code>games</code>.</li>
<li>You should see either a <code>cc1</code> or <code>cc2</code> folder. Drag it into this window.</li>
</ol>
-->
<h2>More levels</h2>
<p>Supports CCL/DAT, C2G, C2M, and ZIP; drag and drop; and both custom and official levels. <a href="https://github.com/eevee/lexys-labyrinth/wiki/Loading-Levels">More details</a></p>
<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 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 -->
</ul>
</section>
<section id="splash-your-levels">
<h2>Make your own (WIP lol)</h2>
<p>Please note that the level editor is <strong>extremely</strong> unfinished, and can't even save yet.</p>
<p><button type="button" id="splash-create-level" class="button-big">Create a level</button></p>
<h2>Create</h2>
<div class="button-row">
<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>
<main id="player" hidden>
<section class="-main-area">
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="overlay-message">
<h1 class="-top"></h1>
<div class="-middle"></div>
<p class="-bottom"></p>
<p class="-keyhint"></p>
<div id="player-main">
<div id="player-controls">
<button class="control-pause" type="button" title="pause">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M2,1 h4 v14 h-4 z M10,1 h4 v14 h-4 z"></path></svg>
<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> <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>
<button class="control-rewind" type="button" title="rewind">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M1,8 7,2 7,14 z M9,8 15,2 15,14 z"></path></svg>
<span class="-optional-label">rewind</span> <span class="keyhint"><kbd>z</kbd></span></button>
<div class="radio-faux-button-set">
<label><input class="control-turn-based" type="checkbox"> <span>Step <br>mode</span></label>
</div>
</div>
<div class="message"></div>
<div class="chips">
<h3>Hearts</h3>
<output></output>
<div id="player-actions">
<button class="action-drop" type="button">
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-drop"></use></svg>
drop <span class="keyhint"><kbd>q</kbd></span></button>
<button class="action-cycle" type="button">
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-cycle"></use></svg>
cycle <span class="keyhint"><kbd>e</kbd></span></button>
<button class="action-swap" type="button">
<svg class="svg-icon" viewBox="0 0 16 16"><use href="#svg-icon-swap"></use></svg>
switch <span class="keyhint"><kbd>c</kbd></span></button>
</div>
<div class="time">
<h3>Time</h3>
<output></output>
</div>
<div class="bonus">
<h3>Bonus</h3>
<output></output>
</div>
<div class="inventory"></div>
</section>
<div id="player-music">
<div id="player-music-left">
🎵 <a id="player-music-title">title</a> by <a id="player-music-author">author</a>
</div>
<div id="player-music-right">
<input id="player-music-volume" type="range" min="0" max="1" step="0.05" value="1">
<input id="player-music-unmute" type="checkbox" checked>
</div>
<audio loop preload="auto">
</div>
<div class="controls">
<div class="play-controls">
<button class="control-pause" type="button">Pause (p)</button>
<button class="control-restart" type="button">Restart</button>
<button class="control-undo" type="button">Undo</button>
<button class="control-rewind" type="button">Rewind (z)</button>
</div>
<div class="demo-controls">
<button class="demo-play" type="button">View replay</button>
<button class="demo-step-1" type="button">Step 1 tic</button>
<button class="demo-step-4" type="button">Step 1 move</button>
<div class="input"></div>
<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>
</div>
<div class="player-level-number">
Level
<output></output>
</div>
<div class="chips">
<h3>
<svg class="svg-icon" viewBox="0 0 16 16" title="Hearts">
<path d="M4,2 C 2,2 1,4 1,6 C 1,8 2,10 4,12 C 6,14 8,15 8,15 C 8,15 10,14 12,12 C 14,10 15,8 15,6 C 15,4 14,2 12,2 C 10,2 8,5 8,5 C 8,5 6,2 4,2 z M12,4 C 12,5 13,6 14,6 C 13,6 12,7 12,8 C 12,7 11,6 10,6 C 11,6 12,5 12,4 z"></path>
</svg>
</h3>
<output></output>
</div>
<div class="time">
<h3>
<svg class="svg-icon" viewBox="0 0 16 16" title="Time">
<path d="M 7,3 A -6,6 0 0 1 13,9 -6,6 0 0 1 7,15 -6,6 0 0 1 1,9 -6,6 0 0 1 7,3 Z M 7,4 A -5,5 0 0 0 2,9 -5,5 0 0 0 7,14 -5,5 0 0 0 12,9 -5,5 0 0 0 7,4 Z"></path>
<!-- cap -->
<path d="M 15,4 12,1 c -1,0 -1,0 -1,1 l 1,1 -2,2 1,1 2,-2 1,1 c 1,0 1,0 1,-1 z"></path>
<!-- arrow -->
<path d="M 8,9 10,6 7,8 Z"></path>
<!-- center -->
<circle cx="7" cy="9" r="1"></circle>
</svg>
</h3>
<output></output>
</div>
<div class="bonus">
<h3>
<svg class="svg-icon" viewBox="0 0 16 16" title="Bonus">
<circle cx="8" cy="8" r="4"></circle>
<path d="m9,7 2,-6 c 2,0 1,1 2,2 1,1 2,0 2,2 z"></path>
<path d="M7,9 5,15 C 3,15 4,14 3,13 2,12 1,13 1,11 z"></path>
</svg>
</h3>
<output></output>
</div>
<div class="player-rules">
<p id="player-rule-compat-lynx" title="This level is known to have compatibility issues with the default Lexy rules, but is playable with the rules it was designed for.">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M 5,1 C 4,1 3,2 3,3 v 9 c 0,1 0,1 -1,1 -1,0 -1,2 0,2 h 9 c 1,0 2,-1 2,-2 V 4 c 0,-1 0,-1 1,-1 1,0 1,-2 0,-2 z m 0,2 h 6 V 4 H 5 Z m 0,3 h 6 V 7 H 5 Z m 0,3 h 6 v 1 H 5 Z m 0,3 h 6 v 1 H 5 Z"></path></svg>
<span>May require Lynx rules</span>
</p>
<p id="player-rule-logic-hidden" title="In this level, wires and logic gates are invisible. X-ray glasses will reveal them.">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="M1,8 a7,7 0 0,1 14,0 7,7 0 0,1 -14,0 M3,8 a5,5 0 0,1 10,0 5,5 0 0,1 -10,0"></path><path d="M2,7 h5 v-5 h2 v5 h5 v2 h-5 v5 h-2 v-5 h-5 v-2"></path><path d="M1,14 L14,1 l1,1 L2,15 l-1,-1"></path></svg>
<span>Logic hidden</span>
</p>
<p id="player-rule-cc1-boots" title="In this level, tools cannot be dropped, and only the tools available in CC1 (cleats, suction boots, fire boots, and flippers) can be picked up.">
<svg class="svg-icon" viewBox="0 0 16 16"><path d="m 1,12 v 3 h 3 l 5,-5 h 3 L 15,7 V 4 L 12,7 H 11 L 9,5 V 4 L 12,1 H 9 L 6,4 v 3 z"></path></svg>
<span>Retro tool mode</span>
</p>
</div>
<div class="inventory"></div>
</section>
<div id="player-music">
🎵 <a id="player-music-title" target="_blank">title</a> by <a id="player-music-author" target="_blank">author</a>
<audio loop preload="auto">
</div>
</div>
<!-- TODO debug panel?
- current tic
- list of actors, or currently pointed-to actor?
<div class="debug">
<button>« 4 tics</button>
<button>« 1 tic</button>
<button>1 tic »</button>
<button>4 tics »</button>
</div>
-->
<form id="player-debug">
<h3>Time</h3>
<table class="-time-controls">
<tr>
<td><button type="button" class="-time-button" data-dt="-1">← 1 tic</button></td>
<td id="player-debug-time-tics">0</td>
<td>tics</td>
<td><button type="button" class="-time-button" data-dt="1">1 tic →</button></td>
</tr>
<tr>
<td><button type="button" class="-time-button" data-dt="-4">← 1 move</button></td>
<td id="player-debug-time-moves">0</td>
<td>moves</td>
<td><button type="button" class="-time-button" data-dt="4">1 move →</button></td>
</tr>
<tr>
<td><button type="button" class="-time-button" data-dt="-20">← 1 s</button></td>
<td id="player-debug-time-secs">0</td>
<td>seconds</td>
<td><button type="button" class="-time-button" data-dt="20">1 s →</button></td>
</tr>
</table>
<div class="-buttons" id="player-debug-time-buttons">
<!-- populated in js -->
</div>
<div id="player-debug-speed" class="radio-faux-button-set">
<label><input type="radio" name="speed" value="1/4"><span class="-button">¼</span></label>
<label><input type="radio" name="speed" value="1/3"><span class="-button"></span></label>
<label><input type="radio" name="speed" value="1/2"><span class="-button">½</span></label>
<label><input type="radio" name="speed" value="1"><span class="-button"><strong>1×</strong></span></label>
<label><input type="radio" name="speed" value="2"><span class="-button">2</span></label>
<label><input type="radio" name="speed" value="3"><span class="-button">3</span></label>
<label><input type="radio" name="speed" value="5"><span class="-button">5</span></label>
<label><input type="radio" name="speed" value="10"><span class="-button">10</span></label>
<label><input type="radio" name="speed" value="25"><span class="-button">25</span></label>
<label><input type="radio" name="speed" value="100"><span class="-button">100</span></label>
</div>
<h3>Inventory</h3>
<div class="-inventory">
<!-- populated in js -->
</div>
<h3>Replay</h3>
<!-- TODO...
play back replay
record replay, including altering it from here
stop replay, without restarting the level
show progress in %, length in tics + time
browse replay? jump to any point? label points???
edit manually?
-->
<div class="-replay-columns">
<div id="player-debug-input"></div>
<div class="-replay-status">
<!-- This should be a fixed height and is always showing one of the following -->
<div class="-none">No replay in progress</div>
<div class="-playback">
<progress max="0" value="0"></progress>
<output>100%</output>
<span>0 tics (0:00s)</span>
<button disabled>Relinquish control</button>
</div>
<div class="-recording">Recording...</div>
</div>
</div>
<!-- js inserts a bunch of stuff here -->
<h3>Misc</h3>
<p>Viewport size:
<select id="player-debug-viewport">
<option value="default" selected>Standard</option>
<option value="12">12 × 12</option>
<option value="16">16 × 16</option>
<option value="24">24 × 24</option>
<option value="32">32 × 32</option>
<option value="max">Entire level</option>
</select>
</p>
<ul>
<li><label><input type="checkbox" name="disable_interpolation"> Disable interpolation</label></li>
<li><label><input type="checkbox" name="show_actor_bboxes"> Show actor bounding boxes</label></li>
<li><label><input type="checkbox" name="show_actor_order"> Show actor order</label></li>
<li><label><input type="checkbox" name="show_actor_tooltips"> Show actor tooltips</label></li>
<!--
<li><label><input type="checkbox" disabled> Freeze time for everything else</label></li>
<li><label><input type="checkbox" disabled> Player is immortal</label></li>
<li><label><input type="checkbox" disabled> Player ignores collision</label></li>
<li><label><input type="checkbox" disabled> Player levitates</label></li>
-->
</ul>
<div class="-buttons" id="player-debug-misc-buttons">
<!-- populated in js -->
</div>
<p>Tip: Middle-click to teleport.</p>
<!-- TODO?
- inspect with mouse
- list of actors, or currently pointed-to actor?
- activate something manually?
- click a button ingame?
- pan viewport (like editor)
- show connections, directions, other editor features
- look under anything
- other game info?
- count tiles?
- total hearts?
- total bonus flags?
-->
</form>
</main>
<main id="editor" hidden>
<header>
<!-- TODO
- close
- export
- delete??
- zoom
also deal with levels vs level /packs/ somehow, not sure how that'll work (including downloading them, yeargh?)
-->
</header>
<div class="level"><!-- level canvas and any overlays go here --></div>
<div class="controls">
<button id="editor-share-url" type="button">Share?</button>
<!--
<p style>
Tip: Right click to color drop.<br>
Tip: Ctrl-click with terrain to replace only the current tile's terrain, rather than overwriting the whole tile.
</p>
<p>Layer: [all/auto] [terrain] [item] [actor] [overlay]</p>
<p>Actor direction: [north] [south] [east] [west]</p>
<p>[ ] Show connections</p>
<p>[ ] Toggle green objects</p>
<p>[ ] Show monster pathing</p>
<p>[ ] Show circuits???</p>
<pre>
Metadata:
xxx / yyy chips required
Time limit: [____]
Title: [__________]
Author: [__________]
map size
</pre>
-->
<header></header>
<div class="editor-canvas">
<div class="-container">
<!-- level canvas and any overlays go here -->
<!-- the container is to allow them to scroll as a single unit -->
</div>
</div>
<nav class="controls"></nav>
<div class="palette"></div>
<!-- TODO:
controls
- play!
- object palette
- choose direction
- choose layer to /modify/: terrain, item, creature, overlay
- stack (place item atop whatever terrain), or replace (placing a tile overwrites the whole cell)
[XXX mode that allows arbitrary stacking of objects?]
- level metadata
- change size
XXX how do i handle thin walls? treat specially, allow drawing/erasing them along edges instead of tiles? ehh then you can't control which tile they're in though... but the game seems to prefer south+east so maybe that works...
hotkeys
- mod a tile on the board: rotate a creature, alter thin walls??
- "pick up" a tile
cool stuff
- set chip count by hand, set extra ones automatically
-->
<div id="editor-statusbar"></div>
</main>
</body>
</html>

230
js/algorithms.js Normal file
View File

@ -0,0 +1,230 @@
import { DIRECTIONS, LAYERS } from './defs.js';
// 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) {
let terrain = cell.get_terrain();
if (! terrain)
continue;
let edgeinfo = DIRECTIONS[edge];
let seen_edges = seen_cells.get(cell) ?? 0;
if (seen_edges & edgeinfo.bit)
continue;
let tile = terrain;
let actor = cell.get_actor();
if (actor && actor.type.contains_wire && (
(actor_mode === 'still' && actor.movement_cooldown === 0) || actor_mode === 'always'))
{
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;
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 (mode === 'none') {
// The wires in this tile never connect to each other
}
else if (mode === 'cross' || (mode === 'autocross' && tile.wire_directions === 0x0f)) {
// This is a cross pattern, so only opposite edges connect
if (tile.wire_directions & edgeinfo.opposite_bit) {
connections |= edgeinfo.opposite_bit;
}
}
else {
// Everything connects
connections |= tile.wire_directions;
}
seen_cells.set(cell, seen_edges | 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)) {
// Obviously don't go backwards, but that doesn't apply if this is our first pass
if (direction === edge && ! is_first)
continue;
if ((connections & dirinfo.bit) === 0)
continue;
let neighbor;
if ((terrain.wire_tunnel_directions ?? 0) & dirinfo.bit) {
// Search in this direction for a matching tunnel
// 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 = levelish.get_neighboring_cell(cell, direction);
}
/*
if (! neighbor || (((neighbor.get_terrain().wire_directions ?? 0) & dirinfo.opposite_bit) === 0)) {
console.log("bailing here", neighbor, direction);
continue;
}
*/
if (! neighbor)
continue;
next.push([neighbor, dirinfo.opposite]);
}
}
pending = next;
is_first = false;
}
return circuit;
}
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 = levelish.cell(x, y);
if (! candidate)
return null;
let neighbor = candidate.get_terrain();
if (! neighbor)
continue;
if ((neighbor.wire_tunnel_directions ?? 0) & dirinfo.opposite_bit) {
if (nesting === 0) {
return candidate;
}
else {
nesting -= 1;
}
}
if ((neighbor.wire_tunnel_directions ?? 0) & dirinfo.bit) {
nesting += 1;
}
}
}

View File

@ -4,33 +4,330 @@ export const DIRECTIONS = {
north: {
movement: [0, -1],
bit: 0x01,
opposite_bit: 0x04,
index: 0,
action: 'up',
left: 'west',
right: 'east',
opposite: 'south',
mirrored: 'north',
flipped: 'south',
},
south: {
movement: [0, 1],
bit: 0x04,
opposite_bit: 0x01,
index: 2,
action: 'down',
left: 'east',
right: 'west',
opposite: 'north',
mirrored: 'south',
flipped: 'north',
},
west: {
movement: [-1, 0],
bit: 0x08,
opposite_bit: 0x02,
index: 3,
action: 'left',
left: 'south',
right: 'north',
opposite: 'east',
mirrored: 'east',
flipped: 'west',
},
east: {
movement: [1, 0],
bit: 0x02,
opposite_bit: 0x08,
index: 1,
action: 'right',
left: 'north',
right: 'south',
opposite: 'west',
mirrored: 'west',
flipped: 'east',
},
};
// Should match the bit ordering above, and CC2's order
export const DIRECTION_ORDER = ['north', 'east', 'south', 'west'];
export const INPUT_BITS = {
drop: 0x01,
down: 0x02,
left: 0x04,
right: 0x08,
up: 0x10,
swap: 0x20,
cycle: 0x40,
// Not real input; used to force advancement for turn-based mode
wait: 0x8000,
};
export const LAYERS = {
terrain: 0,
item: 1,
item_mod: 2,
actor: 3,
vfx: 4,
swivel: 5,
thin_wall: 6,
canopy: 7,
MAX: 8,
};
export const COLLISION = {
real_player1: 0x0001,
real_player2: 0x0002,
real_player: 0x0003,
doppel1: 0x0004,
doppel2: 0x0008,
doppel: 0x000c,
playerlike1: 0x0005,
playerlike2: 0x000a,
playerlike: 0x000f,
block_cc1: 0x0010,
block_cc2: 0x0020, // ice + frame (+ circuit, etc)
bowling_ball: 0x0040, // rolling ball, dynamite
// Monsters are a little complicated, because some of them have special rules, e.g. fireballs
// aren't blocked by fire.
// For a monster's MASK, you should use ONLY ONE of these specific monster bits (if
// appropriate), OR the generic bit -- DO NOT combine them!
monster_generic: 0x0100,
fireball: 0x0200,
bug: 0x0400,
yellow_tank: 0x0800,
rover: 0x1000,
ghost: 0x8000,
// For a tile's COLLISION, use one of these bit combinations
monster_typical: 0x6f00, // everything but ghost and rover
monster_any: 0xff00, // everything including ghost (only used for monster/fire compat flag)
// Combo masks used for matching
all_but_ghost: 0xffff & ~0x8000,
all_but_real_player: 0xffff & ~0x0003,
all: 0xffff,
};
// Item pickup priority, which both actors and items have. An actor will pick up an item if the
// item's priority is greater than or equal to the actor's.
export const PICKUP_PRIORITIES = {
never: 4, // cc2 blocks, never pick anything up
always: 3, // all actors; blue keys, yellow teleporters (everything picks up except cc2 blocks)
// TODO is this even necessary? in cc2 the general rule seems to be that anything stepping on
// an item picks it up, and collision is used to avoid that most of the time
normal: 3, // actors with inventories; most items
player: 1, // players and doppelgangers; red keys (ignored by everything else)
real_player: 0,
};
export const COMPAT_RULESET_LABELS = {
lexy: "Lexy",
steam: "Steam",
'steam-strict': "Steam (strict)",
lynx: "Lynx",
ms: "Microsoft",
custom: "Custom",
};
export const COMPAT_RULESET_ORDER = ['lexy', 'steam', 'steam-strict', 'lynx', 'ms', 'custom'];
// FIXME some of the names of the flags themselves kinda suck
// 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']),
}],
}, {
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']),
}],
}, {
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']),
}],
}, {
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']),
}],
}];
export function compat_flags_for_ruleset(ruleset) {
let compat = {};
for (let category of COMPAT_FLAG_CATEGORIES) {
for (let compatdef of category.flags) {
if (compatdef.rulesets.has(ruleset)) {
compat[compatdef.key] = true;
}
}
}
return compat;
}

547
js/editor/dialogs.js Normal file
View File

@ -0,0 +1,547 @@
import * as c2g from '../format-c2g.js';
import { DialogOverlay, AlertOverlay, flash_button } from '../main-base.js';
import CanvasRenderer from '../renderer-canvas.js';
import { mk, mk_button } from '../util.js';
import * as util from '../util.js';
export class EditorPackMetaOverlay extends DialogOverlay {
constructor(conductor, stored_pack) {
super(conductor);
this.set_title("pack properties");
let dl = mk('dl.formgrid');
this.main.append(dl);
dl.append(
mk('dt', "Title"),
mk('dd', mk('input', {name: 'title', type: 'text', value: stored_pack.title})),
);
// TODO...? what else is a property of the pack itself
this.add_button("save", () => {
let els = this.root.elements;
let title = els.title.value;
if (title !== stored_pack.title) {
stored_pack.title = title;
this.conductor.update_level_title();
}
this.close();
});
this.add_button("nevermind", () => {
this.close();
});
}
}
export class EditorLevelMetaOverlay extends DialogOverlay {
constructor(conductor, stored_level) {
super(conductor);
this.set_title("level properties");
let dl = mk('dl.formgrid');
this.main.append(dl);
let time_limit_input = mk('input', {name: 'time_limit', type: 'number', min: 0, max: 65535, value: stored_level.time_limit});
let time_limit_output = mk('output');
let update_time_limit = () => {
let time_limit = parseInt(time_limit_input.value, 10);
// FIXME need a change event for this tbh?
// FIXME handle NaN; maybe block keydown of not-numbers
time_limit = Math.max(0, Math.min(65535, time_limit));
time_limit_input.value = time_limit;
let text;
if (time_limit === 0) {
text = "No time limit";
}
else {
text = util.format_duration(time_limit);
}
time_limit_output.textContent = text;
};
update_time_limit();
time_limit_input.addEventListener('input', update_time_limit);
let make_size_input = (name) => {
let input = mk('input', {name: name, type: 'number', min: 10, max: 100, value: stored_level[name]});
// TODO maybe block keydown of non-numbers too?
// Note that this is a change event, not an input event, so we don't prevent them from
// erasing the whole value to type a new one
input.addEventListener('change', ev => {
let value = parseInt(ev.target.value, 10);
if (isNaN(value)) {
ev.target.value = stored_level[name];
}
else if (value < 1) {
// Smaller than 10×10 isn't supported by CC2, but LL doesn't mind, so let it
// through if they try it manually
ev.target.value = 1;
}
else if (value > 100) {
ev.target.value = 100;
}
});
return input;
};
dl.append(
mk('dt', "Title"),
mk('dd.-one-field', mk('input', {name: 'title', type: 'text', value: stored_level.title})),
mk('dt', "Author"),
mk('dd.-one-field', mk('input', {name: 'author', type: 'text', value: stored_level.author})),
mk('dt', "Comment"),
mk('dd.-textarea', mk('textarea', {name: 'comment', rows: 4, cols: 20}, stored_level.comment)),
mk('dt', "Time limit"),
mk('dd.-with-buttons',
mk('div.-left',
time_limit_input,
" ",
time_limit_output,
),
mk('div.-right',
mk_button("None", () => {
this.root.elements['time_limit'].value = 0;
update_time_limit();
}),
mk_button("30s", () => {
this.root.elements['time_limit'].value = Math.max(0,
parseInt(this.root.elements['time_limit'].value, 10) - 30);
update_time_limit();
}),
mk_button("+30s", () => {
this.root.elements['time_limit'].value = Math.min(999,
parseInt(this.root.elements['time_limit'].value, 10) + 30);
update_time_limit();
}),
mk_button("Max", () => {
this.root.elements['time_limit'].value = 999;
update_time_limit();
}),
),
),
mk('dt', "Size"),
mk('dd.-with-buttons',
mk('div.-left', make_size_input('size_x'), " × ", make_size_input('size_y')),
mk('div.-right', ...[10, 32, 50, 100].map(size =>
mk_button(`${size}²`, () => {
this.root.elements['size_x'].value = size;
this.root.elements['size_y'].value = size;
}),
)),
),
mk('dt', "Viewport"),
mk('dd',
mk('label',
mk('input', {name: 'viewport', type: 'radio', value: '10'}),
" 10×10 (Chip's Challenge 2 size)"),
mk('br'),
mk('label',
mk('input', {name: 'viewport', type: 'radio', value: '9'}),
" 9×9 (Chip's Challenge 1 size)"),
mk('br'),
mk('label',
mk('input', {name: 'viewport', type: 'radio', value: '', disabled: 'disabled'}),
" Split 10×10 (not yet supported)"),
),
mk('dt', "Blob behavior"),
mk('dd',
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '0'}),
" Deterministic (PRNG + simple convolution)"),
mk('br'),
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '1'}),
" 4 patterns (CC2 default; PRNG + rotating offset)"),
mk('br'),
mk('label',
mk('input', {name: 'blob_behavior', type: 'radio', value: '2'}),
" Extra random (LL default; initial seed is truly random)"),
),
mk('dt', "Options"),
mk('dd', mk('label',
mk('input', {name: 'hide_logic', type: 'checkbox'}),
" Hide wires and logic gates (warning: CC2 also hides pink/black buttons!)")),
mk('dd', mk('label',
mk('input', {name: 'use_cc1_boots', type: 'checkbox'}),
" Use CC1-style inventory (can only pick up the four classic boots; can't drop or cycle)")),
);
this.root.elements['viewport'].value = stored_level.viewport_size;
this.root.elements['blob_behavior'].value = stored_level.blob_behavior;
this.root.elements['hide_logic'].checked = stored_level.hide_logic;
this.root.elements['use_cc1_boots'].checked = stored_level.use_cc1_boots;
// TODO:
// - chips?
// - password???
// - comment
// - use CC1 tools
// - hide logic
// - "unviewable", "read only"
this.add_button("save", () => {
let els = this.root.elements;
let title = els.title.value;
if (title !== stored_level.title) {
stored_level.title = title;
this.conductor.stored_game.level_metadata[this.conductor.level_index].title = title;
this.conductor.update_level_title();
}
let author = els.author.value;
if (author !== stored_level.author) {
stored_level.author = author;
}
// FIXME gotta deal with NaNs here too, sigh, might just need a teeny tiny form library
stored_level.time_limit = Math.max(0, Math.min(65535, parseInt(els.time_limit.value, 10)));
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.crop_level(0, 0, size_x, size_y);
}
stored_level.blob_behavior = parseInt(els.blob_behavior.value, 10);
stored_level.hide_logic = els.hide_logic.checked;
stored_level.use_cc1_boots = els.use_cc1_boots.checked;
let viewport_size = parseInt(els.viewport.value, 10);
if (viewport_size !== 9 && viewport_size !== 10) {
viewport_size = 10;
}
stored_level.viewport_size = viewport_size;
this.conductor.player.update_viewport_size();
this.close();
});
this.add_button("nevermind", () => {
this.close();
});
}
}
// List of levels, used in the player
export class EditorLevelBrowserOverlay extends DialogOverlay {
constructor(conductor) {
super(conductor);
this.set_title("choose a level");
// Set up some infrastructure to lazily display level renders
// FIXME should this use the tileset appropriate for the particular level?
this.renderer = new CanvasRenderer(this.conductor.tilesets['ll'], 32);
this.awaiting_renders = [];
this.observer = new IntersectionObserver((entries, _observer) => {
let any_new = false;
let to_remove = new Set;
for (let entry of entries) {
if (entry.target.classList.contains('--rendered'))
continue;
let index = this._get_index(entry.target);
if (entry.isIntersecting) {
this.awaiting_renders.push(index);
any_new = true;
}
else {
to_remove.add(index);
}
}
this.awaiting_renders = this.awaiting_renders.filter(index => ! to_remove.has(index));
if (any_new) {
this.schedule_level_render();
}
},
{ root: this.main },
);
this.list = mk('ol.editor-level-browser');
this.selection = this.conductor.level_index;
for (let [i, meta] of conductor.stored_game.level_metadata.entries()) {
this.list.append(this._make_list_item(i, meta));
}
this.list.childNodes[this.selection].classList.add('--selected');
this.main.append(
mk('p', "Drag to rearrange. Changes are immediate!"),
this.list,
);
this.list.addEventListener('click', ev => {
let index = this._get_index(ev.target);
if (index === null)
return;
this._select(index);
});
this.list.addEventListener('dblclick', ev => {
let index = this._get_index(ev.target);
if (index !== null && this.conductor.change_level(index)) {
this.close();
}
});
this.sortable = new Sortable(this.list, {
group: 'editor-levels',
onEnd: ev => {
if (ev.oldIndex === ev.newIndex)
return;
this._move_level(ev.oldIndex, ev.newIndex);
this.undo_stack.push(() => {
this.list.insertBefore(
this.list.childNodes[ev.newIndex],
this.list.childNodes[ev.oldIndex + (ev.oldIndex < ev.newIndex ? 0 : 1)]);
this._move_level(ev.newIndex, ev.oldIndex);
});
this.undo_button.disabled = false;
},
});
// FIXME ring buffer?
this.undo_stack = [];
// Left buttons
this.undo_button = this.add_button("undo", () => {
if (! this.undo_stack.length)
return;
let undo = this.undo_stack.pop();
undo();
this.undo_button.disabled = ! this.undo_stack.length;
});
this.undo_button.disabled = true;
this.add_button("create", () => {
let index = this.selection + 1;
let stored_level = this.conductor.editor._make_empty_level(index + 1, 32, 32);
this.conductor.editor.move_level(stored_level, index);
this._after_insert_level(stored_level, index);
this.undo_stack.push(() => {
this._delete_level(index);
});
this.undo_button.disabled = false;
});
this.add_button("duplicate", () => {
let index = this.selection + 1;
let stored_level = this.conductor.editor.duplicate_level(this.selection);
this._after_insert_level(stored_level, index);
this.undo_stack.push(() => {
this._delete_level(index);
});
this.undo_button.disabled = false;
});
this.delete_button = this.add_button("delete", () => {
let index = this.selection;
if (index === this.conductor.level_index) {
new AlertOverlay(this.conductor, "You can't delete the level you have open.").open();
return;
}
// Snag a copy of the serialized level for undo purposes
// FIXME can't undo deleting a corrupt level
let meta = this.conductor.stored_game.level_metadata[index];
let serialized_level = window.localStorage.getItem(meta.key);
this._delete_level(index);
this.undo_stack.push(() => {
let stored_level = meta.stored_level ?? c2g.parse_level(
util.bytestring_to_buffer(serialized_level), index + 1);
this.conductor.editor.move_level(stored_level, index);
if (this.selection >= index) {
this.selection += 1;
}
this._after_insert_level(stored_level, index);
});
this.undo_button.disabled = false;
});
this._update_delete_button();
// Right buttons
this.add_button_gap();
this.add_button("open", () => {
if (this.selection === this.conductor.level_index || this.conductor.change_level(this.selection)) {
this.close();
}
});
this.add_button("nevermind", () => {
this.close();
});
}
_make_list_item(index, meta) {
let li = mk('li',
{'data-index': index},
mk('div.-preview'),
mk('div.-number', {}, meta.number),
mk('div.-title', {}, meta.error ? "(error!)" : meta.title),
);
if (meta.error) {
li.classList.add('--error');
}
else {
this.observer.observe(li);
}
return li;
}
renumber_levels(start_index, end_index = null) {
end_index = end_index ?? this.conductor.stored_game.level_metadata.length - 1;
for (let i = start_index; i <= end_index; i++) {
let li = this.list.childNodes[i];
let meta = this.conductor.stored_game.level_metadata[i];
li.setAttribute('data-index', i);
li.querySelector('.-number').textContent = meta.number;
}
}
_get_index(element) {
let li = element.closest('li');
if (! li)
return null;
return parseInt(li.getAttribute('data-index'), 10);
}
_select(index) {
this.list.childNodes[this.selection].classList.remove('--selected');
this.selection = index;
this.list.childNodes[this.selection].classList.add('--selected');
this._update_delete_button();
}
_update_delete_button() {
this.delete_button.disabled = !! (this.selection === this.conductor.level_index);
}
schedule_level_render() {
if (this._handle)
return;
this._handle = setTimeout(() => { this.render_level() }, 50);
}
render_level() {
this._handle = null;
let t0 = performance.now();
while (true) {
if (this.awaiting_renders.length === 0)
return;
let index = this.awaiting_renders.shift();
let element = this.list.childNodes[index];
// FIXME levels may have been renumbered since this was queued, whoops
let stored_level = this.conductor.stored_game.load_level(index);
this.renderer.set_level(stored_level);
this.renderer.set_viewport_size(stored_level.size_x, stored_level.size_y);
this.renderer.draw_static_region(0, 0, stored_level.size_x, stored_level.size_y);
let canvas = mk('canvas', {
width: stored_level.size_x * this.renderer.tileset.size_x / 4,
height: stored_level.size_y * this.renderer.tileset.size_y / 4,
});
canvas.getContext('2d').drawImage(this.renderer.canvas, 0, 0, canvas.width, canvas.height);
element.querySelector('.-preview').append(canvas);
element.classList.add('--rendered');
if (performance.now() - t0 > 10)
break;
}
this.schedule_level_render();
}
expire(index) {
let li = this.list.childNodes[index];
li.classList.remove('--rendered');
li.querySelector('.-preview').textContent = '';
}
_after_insert_level(stored_level, index) {
this.list.insertBefore(
this._make_list_item(index, this.conductor.stored_game.level_metadata[index]),
this.list.childNodes[index]);
this._select(index);
this.renumber_levels(index + 1);
}
_delete_level(index) {
let num_levels = this.conductor.stored_game.level_metadata.length;
this.conductor.editor.move_level(index, null);
this.list.childNodes[this.selection].classList.remove('--selected');
this.list.childNodes[index].remove();
if (index === num_levels - 1) {
this.selection -= 1;
}
else {
this.renumber_levels(index);
}
this.list.childNodes[this.selection].classList.add('--selected');
}
_move_level(from_index, to_index) {
this.conductor.editor.move_level(from_index, to_index);
let selection = this.selection;
if (from_index < to_index) {
this.renumber_levels(from_index, to_index);
if (from_index < selection && selection <= to_index) {
selection -= 1;
}
}
else {
this.renumber_levels(to_index, from_index);
if (to_index <= selection && selection < from_index) {
selection += 1;
}
}
if (this.selection === from_index) {
this.selection = to_index;
}
else {
this.selection = selection;
}
this._update_delete_button();
}
}
export class EditorShareOverlay extends DialogOverlay {
constructor(conductor, url) {
super(conductor);
this.set_title("give this to friends");
this.main.append(mk('p', "Give this URL out to let others try your level:"));
this.main.append(mk('p.editor-share-url', {}, url));
let copy_button = mk('button', {type: 'button'}, "Copy to clipboard");
copy_button.addEventListener('click', ev => {
flash_button(ev.target);
navigator.clipboard.writeText(url);
});
this.main.append(copy_button);
let ok = mk('button', {type: 'button'}, "neato");
ok.addEventListener('click', () => {
this.close();
});
this.footer.append(ok);
}
}
export class EditorExportFailedOverlay extends DialogOverlay {
constructor(conductor, errors, _warnings) {
// TODO support warnings i guess
super(conductor);
this.set_title("export didn't go so well");
this.main.append(mk('p', "Whoops! I tried very hard to export your level, but it didn't work out. Sorry."));
let ul = mk('ul.editor-export-errors');
// TODO structure the errors better and give them names out here, also reduce duplication,
// also be clear about which are recoverable or not
for (let error of errors) {
ul.append(mk('li', error));
}
this.main.append(ul);
this.add_button("oh well", () => {
this.close();
});
}
}

1589
js/editor/editordefs.js Normal file

File diff suppressed because it is too large Load Diff

656
js/editor/helpers.js Normal file
View File

@ -0,0 +1,656 @@
// Small helper classes used by the editor, often with their own UI for the SVG overlay.
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, cx: sx + 0.5, cy: sy + 0.5});
this.line = mk_svg('line.-arrow', {});
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.sx = sx;
this.sy = sy;
this.dx = dx;
this.dy = dy;
this._update_line_endpoints();
}
set_source(sx, sy) {
this.sx = sx;
this.sy = sy;
this.source.setAttribute('cx', sx + 0.5);
this.source.setAttribute('cy', sy + 0.5);
this._update_line_endpoints();
}
set_dest(dx, dy) {
this.dx = dx;
this.dy = dy;
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);
}
}
}
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.size_text = mk_svg('text.overlay-edit-tip');
this.owner.svg_group.append(this.element, this.size_text);
this.rect = null;
}
set_extrema(x0, y0, x1, y1) {
this.rect = new DOMRect(Math.min(x0, x1), Math.min(y0, y1), Math.abs(x0 - x1) + 1, Math.abs(y0 - y1) + 1);
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);
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() {
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();
}
}
export class Selection {
constructor(editor) {
this.editor = editor;
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);
// 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.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.is_empty)
return true;
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(mode) {
return new PendingRectangularSelection(this, mode);
}
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._add_rect(rect),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_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.is_empty)
return;
if (! this.floated_cells) {
console.error("Can't move a non-floating selection");
return;
}
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() {
// FIXME behavior when floating is undefined
if (this.is_empty)
return;
let old_cells = this.cells;
this.editor._do(
() => this._clear(),
() => {
this._set_from_set(old_cells);
},
false,
);
}
_clear() {
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) {
console.error("Trying to float a selection that's already floating");
return;
}
let floated_cells = new Map;
let stored_level = this.editor.stored_level;
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.set(n, cell.map(tile => tile ? {...tile} : null));
}
else {
floated_cells.set(n, cell);
this.editor.replace_cell(cell, this.editor.make_blank_cell(x, y));
}
}
this.editor._do(
() => {
this.floated_cells = floated_cells;
this.floated_offset = [0, 0];
this._init_floated_canvas();
this.ring_element.classList.add('--floating');
},
() => 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;
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;
let n2 = stored_level.coords_to_scalar(x, y);
this.editor.replace_cell(stored_level.linear_cells[n2], cell);
}
}
// 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();
// 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._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,
);
}
// 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;
}
// Redraw the selection canvas from scratch
redraw() {
if (! this.floated_canvas)
return;
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 make more stuff respect this (more things should go through Editor for undo reasons anyway)
}

2314
js/editor/main.js Normal file

File diff suppressed because it is too large Load Diff

2699
js/editor/mouseops.js Normal file

File diff suppressed because it is too large Load Diff

290
js/editor/tile-overlays.js Normal file
View File

@ -0,0 +1,290 @@
import { TransientOverlay } from '../main-base.js';
import { mk, mk_svg } from '../util.js';
// FIXME could very much stand to have a little animation when appearing
class TileEditorOverlay extends TransientOverlay {
constructor(conductor) {
let root = mk('form.editor-popup-tile-editor');
super(conductor, root);
this.editor = conductor.editor;
this.tile = null;
}
edit_tile(tile, cell) {
this.tile = tile;
this.cell = cell;
this.needs_undo_entry = false;
}
// Please call this BEFORE actually modifying the tile; it's important for undo!
mark_dirty() {
if (this.cell) {
if (! this.needs_undo_entry) {
// We are ABOUT to mutate this tile for the first time; swap it out with a clone in
// preparation for making an undo entry when this overlay closes
this.pristine_tile = this.tile;
this.tile = {...this.tile};
this.cell[this.tile.type.layer] = this.tile;
this.needs_undo_entry = true;
}
this.editor.mark_cell_dirty(this.cell);
}
else if (this.tile === this.editor.fg_tile) {
// The change hasn't happened yet! Don't redraw until we return to the event loop
setTimeout(() => this.editor.redraw_foreground_tile(), 0);
}
else if (this.tile === this.editor.bg_tile) {
setTimeout(() => this.editor.redraw_background_tile(), 0);
}
}
close() {
if (this.needs_undo_entry) {
// This will be a no-op the first time since the tile was already swapped, but it's
// important for redo
this.editor._assign_tile(this.cell, this.tile.type.layer, this.tile, this.pristine_tile);
this.editor.commit_undo();
}
super.close();
}
static configure_tile_defaults(tile) {
// FIXME maybe this should be on the tile type, so it functions as documentation there?
}
}
class LetterTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
this.root.append(mk('h3', "Letter tile"));
let list = mk('ol.editor-letter-tile-picker');
this.root.append(list);
this.glyph_elements = {};
let add = glyph => {
let input = mk('input', {type: 'radio', name: 'glyph', value: glyph});
this.glyph_elements[glyph] = input;
let item = mk('li', mk('label', input, mk('span.-glyph', glyph)));
list.append(item);
};
let arrows = ["⬆", "➡", "⬇", "⬅"];
for (let c = 32; c < 96; c++) {
let glyph = String.fromCharCode(c);
add(glyph);
// Add the arrows to the ends of the rows
if (c % 16 === 15) {
add(arrows[(c - 47) / 16]);
}
}
list.addEventListener('change', ev => {
if (this.tile) {
this.mark_dirty();
this.tile.overlaid_glyph = this.root.elements['glyph'].value;
}
});
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
this.root.elements['glyph'].value = tile.overlaid_glyph;
}
static configure_tile_defaults(tile) {
tile.type.populate_defaults(tile);
}
}
class HintTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
this.root.append(mk('h3', "Hint text"));
this.text = mk('textarea.editor-hint-tile-text');
this.root.append(this.text);
this.text.addEventListener('change', ev => {
if (this.tile && this.text.value !== this.tile.hint_text) {
this.mark_dirty();
this.tile.hint_text = this.text.value;
}
});
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
this.text.value = tile.hint_text ?? "";
}
static configure_tile_defaults(tile) {
tile.hint_text = "";
}
}
class FrameBlockTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
let svg_icons = [];
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
);
svg_icons.push(symbol);
}
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
));
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
));
this.root.append(mk('h3', "Arrows"));
let arrow_list = mk('ol.editor-directional-block-tile-arrows.editor-tile-editor-svg-parts');
// Arrange the arrows in a grid
for (let [direction, icon] of [
[null, mk_svg('path', {d: 'M 8,16 v -8 h 8'})],
['north', mk_svg('path', {d: 'M 0,12 h 16 l -8,-8 z'})],
[null, mk_svg('path', {d: 'M 0,8 h 8 v 8'})],
['west', mk_svg('path', {d: 'M 12,16 v -16 l -8,8 z'})],
[null, null],
['east', mk_svg('path', {d: 'M 4,0 v 16 l 8,-8 z'})],
[null, mk_svg('path', {d: 'M 16,8 h -8 v -8'})],
['south', mk_svg('path', {d: 'M 16,4 h -16 l 8,8 z'})],
[null, mk_svg('path', {d: 'M 8,0 v 8 h -8'})],
]) {
let li = mk('li');
let svg;
if (icon) {
svg = mk_svg('svg', {viewBox: '0 0 16 16'}, icon);
}
if (direction === null) {
if (svg) {
li.append(svg);
}
}
else {
let input = mk('input', {type: 'checkbox', name: 'direction', value: direction});
li.append(mk('label', input, svg));
}
arrow_list.append(li);
}
arrow_list.addEventListener('change', ev => {
if (! this.tile)
return;
this.mark_dirty();
if (ev.target.checked) {
this.tile.arrows.add(ev.target.value);
}
else {
this.tile.arrows.delete(ev.target.value);
}
});
this.root.append(arrow_list);
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
for (let input of this.root.elements['direction']) {
input.checked = tile.arrows.has(input.value);
}
}
static configure_tile_defaults(tile) {
}
}
class RailroadTileEditor extends TileEditorOverlay {
constructor(conductor) {
super(conductor);
let svg_icons = [];
for (let center of [[16, 0], [16, 16], [0, 16], [0, 0]]) {
let symbol = mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('circle', {cx: center[0], cy: center[1], r: 3}),
mk_svg('circle', {cx: center[0], cy: center[1], r: 13}),
);
svg_icons.push(symbol);
}
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: -2, y: 3, width: 20, height: 10}),
));
svg_icons.push(mk_svg('svg', {viewBox: '0 0 16 16'},
mk_svg('rect', {x: 3, y: -2, width: 10, height: 20}),
));
this.root.append(mk('h3', "Tracks"));
let track_list = mk('ul.editor-railroad-tile-tracks.editor-tile-editor-svg-parts');
// Shown as two rows, this puts the straight parts first and the rest in a circle
let track_order = [4, 1, 2, 5, 0, 3];
for (let i of track_order) {
let input = mk('input', {type: 'checkbox', name: 'track', value: i});
track_list.append(mk('li', mk('label', input, svg_icons[i])));
}
track_list.addEventListener('change', ev => {
if (! this.tile)
return;
this.mark_dirty();
let bit = 1 << ev.target.value;
if (ev.target.checked) {
this.tile.tracks |= bit;
}
else {
this.tile.tracks &= ~bit;
}
});
this.root.append(track_list);
this.root.append(mk('h3', "Switch"));
let switch_list = mk('ul.editor-railroad-tile-tracks.--switch.editor-tile-editor-svg-parts');
for (let i of track_order) {
let input = mk('input', {type: 'radio', name: 'switch', value: i});
switch_list.append(mk('li', mk('label', input, svg_icons[i].cloneNode(true))));
}
// TODO if they remove a track it should change the switch
// TODO if they pick a track that's missing it should add it
switch_list.addEventListener('change', ev => {
if (this.tile) {
this.mark_dirty();
this.tile.track_switch = parseInt(ev.target.value, 10);
}
});
this.root.append(switch_list);
// TODO need a way to set no actor at all
// TODO initial actor facing (maybe only if there's an actor in the cell)
}
edit_tile(tile, cell) {
super.edit_tile(tile, cell);
for (let input of this.root.elements['track']) {
input.checked = !! (tile.tracks & (1 << input.value));
}
if (tile.track_switch === null) {
this.root.elements['switch'].value = '';
}
else {
this.root.elements['switch'].value = tile.track_switch;
}
}
static configure_tile_defaults(tile) {
}
}
export const TILES_WITH_PROPS = {
floor_letter: LetterTileEditor,
hint: HintTileEditor,
frame_block: FrameBlockTileEditor,
railroad: RailroadTileEditor,
// TODO various wireable tiles (hmm not sure how that ui works)
// TODO initial value of counter
// TODO cloner arrows (should this be automatic unless you set them explicitly?)
// TODO later, custom floor/wall selection
};

214
js/format-base.js Normal file
View File

@ -0,0 +1,214 @@
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 {
constructor(initial_force_floor_direction, blob_seed, inputs = null, step_parity = null, tw_seed = 0) {
this.initial_force_floor_direction = initial_force_floor_direction;
this.blob_seed = blob_seed;
this.step_parity = step_parity;
this.tw_seed = tw_seed;
this.inputs = inputs ?? new Uint8Array;
this.duration = this.inputs.length;
this.cursor = 0;
}
configure_level(level) {
level.force_floor_direction = this.initial_force_floor_direction;
level._blob_modifier = this.blob_seed;
level._tw_rng = this.tw_seed;
if (this.step_parity !== null) {
level.step_parity = this.step_parity;
}
}
get(t) {
if (this.duration <= 0) {
return 0;
}
else if (t < this.duration) {
return this.inputs[t];
}
else {
// Last input is implicitly repeated indefinitely
return this.inputs[this.duration - 1];
}
}
set(t, input) {
if (t >= this.inputs.length) {
let new_inputs = new Uint8Array(this.inputs.length + 1024);
for (let i = 0; i < this.inputs.length; i++) {
new_inputs[i] = this.inputs[i];
}
this.inputs = new_inputs;
}
this.inputs[t] = input;
if (t >= this.duration) {
this.duration = t + 1;
}
}
clone() {
let new_inputs = new Uint8Array(this.duration);
for (let i = 0; i < this.duration; i++) {
new_inputs[i] = this.inputs[i];
}
return new this.constructor(this.initial_force_floor_direction, this.blob_seed, new_inputs);
}
}
// Small shared helper methods for navigating a StoredLevel or Level
export class LevelInterface {
// Expected attributes:
// .size_x
// .size_y
// .linear_cells
scalar_to_coords(n) {
return [n % this.size_x, Math.floor(n / this.size_x)];
}
coords_to_scalar(x, y) {
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);
}
cell(x, y) {
if (this.is_point_within_bounds(x, y)) {
return this.linear_cells[this.coords_to_scalar(x, y)];
}
else {
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 {
constructor(number) {
super();
// TODO still not sure this belongs here
this.number = number; // one-based
this.title = '';
this.author = '';
this.password = null;
this.comment = '';
// A number is a specified count; the default of null means that the chips are counted on
// level init, as in CC2
this.chips_required = null;
this.time_limit = 0;
this.viewport_size = 9;
this.extra_chunks = [];
this.use_cc1_boots = false;
// What we were parsed from: 'ccl', 'c2m', or null
this.format = null;
// Whether we use LL features that don't exist in CC2; null means we don't know
this.uses_ll_extensions = null;
// 0 - deterministic (PRNG + simple convolution)
// 1 - 4 patterns (default; PRNG + rotating through 0-3)
// 2 - extra random (like deterministic, but initial seed is "actually" random)
this.blob_behavior = 1;
this.hide_logic = false;
// Lazy-loading that allows for checking existence (see methods below)
// TODO this needs a better interface, these get accessed too much atm
this._replay = null;
this._replay_data = null;
this._replay_decoder = null;
this.size_x = 0;
this.size_y = 0;
this.linear_cells = [];
// Maps of button positions to trap/cloner positions, as scalars
// 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 = [];
}
check() {
}
get has_replay() {
return this._replay || (this._replay_data && this._replay_decoder);
}
get replay() {
if (! this._replay) {
this._replay = this._replay_decoder(this._replay_data);
}
return this._replay;
}
}
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;
// Simple objects containing keys that are usually:
// title: level title
// index: level index, used internally only
// number: level number (may not match index due to C2G shenanigans)
// error: any error received while loading the level
// bytes: Uint8Array of the encoded level data
this.level_metadata = [];
// Sparse/optional array of Replays, generally from an ancillary file like a TWS
// TODO unclear if this is a good API for this
this.level_replays = [];
}
// TODO this may or may not work sensibly when correctly following a c2g
load_level(index) {
let meta = this.level_metadata[index];
if (! meta)
throw new util.LLError(`No such level number ${index}`);
if (meta.error)
throw meta.error;
if (meta.stored_level) {
// The editor stores inflated levels at times, so respect that
return meta.stored_level;
}
// Otherwise, attempt to load the level
let stored_level = this._level_loader(meta);
if (! stored_level.has_replay && this.level_replays[index]) {
stored_level._replay = this.level_replays[index];
}
return stored_level;
}
}
export const StoredGame = StoredPack;

2236
js/format-c2g.js Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,7 @@
import * as util from './format-util.js';
import { DIRECTIONS, LAYERS } from './defs.js';
import * as format_base from './format-base.js';
import TILE_TYPES from './tiletypes.js';
import * as util from './util.js';
const TILE_ENCODING = {
0x00: 'floor',
@ -8,10 +10,10 @@ const TILE_ENCODING = {
0x03: 'water',
0x04: 'fire',
0x05: 'wall_invisible',
0x06: 'thinwall_n',
0x07: 'thinwall_w',
0x08: 'thinwall_s',
0x09: 'thinwall_e',
0x06: ['thin_walls', {edges: DIRECTIONS['north'].bit}],
0x07: ['thin_walls', {edges: DIRECTIONS['west'].bit}],
0x08: ['thin_walls', {edges: DIRECTIONS['south'].bit}],
0x09: ['thin_walls', {edges: DIRECTIONS['east'].bit}],
// This is MSCC's incomprehensible non-directional dirt block, which needs a direction for Lynx
// purposes; Tile World defaults it to north
0x0a: ['dirt_block', 'north'],
@ -53,7 +55,7 @@ const TILE_ENCODING = {
0x2d: 'gravel',
0x2e: 'popwall',
0x2f: 'hint',
0x30: 'thinwall_se',
0x30: ['thin_walls', {edges: DIRECTIONS['south'].bit | DIRECTIONS['east'].bit}],
0x31: 'cloner',
0x32: 'force_floor_all',
0x33: 'bogus_player_drowned',
@ -61,7 +63,7 @@ const TILE_ENCODING = {
0x35: 'bogus_player_burned',
0x36: 'wall_invisible', // unused
0x37: 'wall_invisible', // unused
0x38: 'wall_invisible', // unused
0x38: 'ice_block', // unused, but co-opted by pgchip
0x39: 'bogus_player_win',
0x3a: 'bogus_player_win',
0x3b: 'bogus_player_win',
@ -118,19 +120,108 @@ const TILE_ENCODING = {
0x6e: ['player', 'south'],
0x6f: ['player', 'east'],
};
function parse_level(buf, number) {
let level = new util.StoredLevel(number);
const REVERSE_TILE_ENCODING = {};
for (let [tile_byte, spec] of Object.entries(TILE_ENCODING)) {
tile_byte = parseInt(tile_byte, 10); // these are keys so they get stringified ugh
if (0x36 <= tile_byte && tile_byte <= 0x37) {
// These are unused tiles which get turned into invisible walls; don't encode invisible
// walls as them! (0x38 is also "unused", but pgchip turns it into ice block.)
continue;
}
let name, arg;
if (spec instanceof Array) {
[name, arg] = spec;
}
else {
name = spec;
arg = null;
}
let rev_spec = REVERSE_TILE_ENCODING[name];
if (! rev_spec) {
rev_spec = {};
REVERSE_TILE_ENCODING[name] = rev_spec;
}
if (arg === null || tile_byte === 0x0a) {
// Special case: 0x0a is MSCC's undirected dirt block, which needs to coexist with the
// directed "clone" blocks
rev_spec['all'] = tile_byte;
}
else if (typeof arg === 'string') {
// This is a direction
rev_spec[arg] = tile_byte;
}
else {
// This is the thin_walls argument structure
rev_spec[arg.edges] = tile_byte;
}
}
function decode_password(bytes, start, len) {
let password = [];
for (let i = 0; i < len; i++) {
password.push(bytes[start + i] ^ 0x99);
}
return String.fromCharCode.apply(null, password);
}
export function parse_level_metadata(bytes) {
let meta = {};
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// Level number; rest of level header is unused
meta.number = view.getUint16(0, true);
// Map layout
// Same structure twice, for the two layers
let p = 8;
for (let l = 0; l < 2; l++) {
let layer_length = view.getUint16(p, true);
p += 2 + layer_length;
}
// Optional metadata fields
let meta_length = view.getUint16(p, true);
p += 2;
let end = p + meta_length;
while (p < end) {
// Common header
let field_type = view.getUint8(p, true);
let field_length = view.getUint8(p + 1, true);
p += 2;
if (field_type === 0x03) {
// Title, including trailing NUL
meta.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
else if (field_type === 0x06) {
// Password, with trailing NUL, and XORed with 0x99 (???)
meta.password = decode_password(bytes, p, field_length - 1);
}
p += field_length;
}
return meta;
}
function parse_level(bytes, number) {
let level = new format_base.StoredLevel(number);
level.only_custom_connections = true;
level.format = 'ccl';
level.uses_ll_extensions = false;
level.chips_required = 0;
// Map size is always fixed as 32x32 in CC1
level.size_x = 32;
level.size_y = 32;
for (let i = 0; i < 1024; i++) {
level.linear_cells.push(new util.StoredCell);
level.linear_cells.push(new format_base.StoredCell);
}
level.use_cc1_boots = true;
let view = new DataView(buf);
let bytes = new Uint8Array(buf);
let view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
// Header
let level_number = view.getUint16(0, true);
@ -141,6 +232,7 @@ function parse_level(buf, number) {
let unknown = view.getUint16(6, true);
// Same structure twice, for the two layers
let p = 8;
let hint_tiles = [];
for (let l = 0; l < 2; l++) {
let layer_length = view.getUint16(p, true);
p += 2;
@ -161,17 +253,21 @@ function parse_level(buf, number) {
// TODO could be more forgiving for goofy levels doing goofy things
if (! spec) {
let [x, y] = level.scalar_to_coords(c);
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y}) in level ${number}`);
throw new Error(`Invalid tile byte 0x${tile_byte.toString(16)} at (${x}, ${y})`);
}
let name, direction;
let name, extra;
if (spec instanceof Array) {
[name, direction] = spec;
[name, extra] = spec;
if (typeof extra === 'string') {
extra = {direction: extra};
}
}
else {
name = spec;
extra = {};
}
let type = TILE_TYPES[name];
let tile = {type: TILE_TYPES[name], ...extra};
for (let i = 0; i < count; i++) {
if (c >= 1024)
@ -189,7 +285,22 @@ function parse_level(buf, number) {
continue;
}
cell.unshift({type, direction});
// pgchip grants directions to ice blocks on cloners by putting a clone block
// beneath them instead
if (l === 1 && 0x0e <= tile_byte && tile_byte <= 0x11 &&
cell[LAYERS.actor] && cell[LAYERS.actor].type.name === 'ice_block')
{
cell[LAYERS.actor].direction = extra.direction;
let type = TILE_TYPES['cloner'];
cell[type.layer] = {type};
continue;
}
let new_tile = {...tile};
cell[tile.type.layer] = new_tile;
if (new_tile.type.name === 'hint') {
hint_tiles.push(new_tile);
}
}
}
if (c !== 1024)
@ -198,9 +309,8 @@ function parse_level(buf, number) {
// Fix the "floor/empty" nonsense here by adding floor to any cell with no terrain on bottom
for (let cell of level.linear_cells) {
if (cell.length === 0 || cell[0].type.draw_layer !== 0) {
// No terrain; insert a floor
cell.unshift({ type: TILE_TYPES['floor'] });
if (! cell[LAYERS.terrain]) {
cell[LAYERS.terrain] = { type: TILE_TYPES['floor'] };
}
// TODO we could also deal with weird cases where there's terrain /on top of/ something
// else: things underwater, the quirk where a glider will erase the item underneath...
@ -225,11 +335,11 @@ function parse_level(buf, number) {
}
else if (field_type === 0x03) {
// Title, including trailing NUL
level.title = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
level.title = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
else if (field_type === 0x04) {
// Trap linkages (MSCC only, not in Lynx or CC2)
let field_view = new DataView(buf.slice(p, p + field_length));
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
let q = 0;
while (q < field_length) {
let button_x = field_view.getUint16(q + 0, true);
@ -238,12 +348,18 @@ function parse_level(buf, number) {
let trap_y = field_view.getUint16(q + 6, true);
// Fifth u16 is always zero, possibly live game state
q += 10;
level.custom_trap_wiring[button_x + button_y * level.size_x] = trap_x + trap_y * level.size_x;
// Connections are ignored if they're on the wrong tiles anyway, and we use a single
// mapping that's a bit more flexible, so only store valid connections
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.set(s, d);
}
}
}
else if (field_type === 0x05) {
// Cloner linkages (MSCC only, not in Lynx or CC2)
let field_view = new DataView(buf.slice(p, p + field_length));
let field_view = new DataView(bytes.buffer, bytes.byteOffset + p, field_length);
let q = 0;
while (q < field_length) {
let button_x = field_view.getUint16(q + 0, true);
@ -251,25 +367,34 @@ function parse_level(buf, number) {
let cloner_x = field_view.getUint16(q + 4, true);
let cloner_y = field_view.getUint16(q + 6, true);
q += 8;
level.custom_cloner_wiring[button_x + button_y * level.size_x] = cloner_x + cloner_y * level.size_x;
// Connections are ignored if they're on the wrong tiles anyway, and we use a single
// mapping that's a bit more flexible, so only store valid connections
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.set(s, d);
}
}
}
else if (field_type === 0x06) {
// Password, with trailing NUL, and otherwise XORed with 0x99 (?!)
let password = [];
for (let i = 0; i < field_length - 1; i++) {
password.push(view.getUint8(p + i, true) ^ 0x99);
}
level.password = String.fromCharCode.apply(null, password);
// Password, with trailing NUL, and otherwise XORed with 0x99 (???)
level.password = decode_password(bytes, p, field_length - 1);
}
else if (field_type === 0x07) {
// Hint, including trailing NUL, of course
level.hint = util.string_from_buffer_ascii(buf.slice(p, p + field_length - 1));
let hint = util.string_from_buffer_ascii(bytes, p, field_length - 1);
for (let tile of hint_tiles) {
tile.hint_text = hint;
}
}
else if (field_type === 0x08) {
// Password, but not encoded
// TODO ???
}
else if (field_type === 0x09) {
// EXTENSION: Author, including trailing NUL
level.author = util.string_from_buffer_ascii(bytes, p, field_length - 1);
}
else if (field_type === 0x0a) {
// Initial actor order
// TODO ??? should i... trust this...
@ -280,8 +405,13 @@ function parse_level(buf, number) {
return level;
}
// This thin wrapper is passed to StoredGame as the parser function
function _parse_level_from_stored_meta(meta) {
return parse_level(meta.bytes, meta.number);
}
export function parse_game(buf) {
let game = new util.StoredGame;
let game = new format_base.StoredGame(null, _parse_level_from_stored_meta);
let full_view = new DataView(buf);
let magic = full_view.getUint32(0, true);
@ -293,6 +423,10 @@ export function parse_game(buf) {
// OK
// TODO tile world convention, use lynx rules
}
else if (magic === 0x0003aaac) {
// OK
// TODO add in ice block i guess???
}
else {
throw new Error(`Unrecognized magic number ${magic.toString(16)}`);
}
@ -303,12 +437,274 @@ export function parse_game(buf) {
let p = 6;
for (let l = 1; l <= level_count; l++) {
let length = full_view.getUint16(p, true);
let level_buf = buf.slice(p + 2, p + 2 + length);
let bytes = new Uint8Array(buf, p + 2, length);
p += 2 + length;
let level = parse_level(level_buf, l);
game.levels.push(level);
let meta;
try {
meta = parse_level_metadata(bytes);
}
catch (e) {
meta = {error: e};
}
meta.index = l - 1;
meta.bytes = bytes;
game.level_metadata.push(meta);
}
return game;
}
export class CCLEncodingErrors extends util.LLError {
constructor(errors) {
super("Failed to encode level as CCL");
this.errors = errors;
}
}
export function synthesize_level(stored_level) {
let errors = [];
if (stored_level.size_x !== 32) {
errors.push(`Level width must be 32, not ${stored_level.size_x}`);
}
if (stored_level.size_y !== 32) {
errors.push(`Level width must be 32, not ${stored_level.size_y}`);
}
// TODO might also want the tile world "lynx mode" magic number, or pgchip's ice block rules
let magic = 0x0002aaac;
let top_layer = [];
let bottom_layer = [];
let hint_text = null;
let trap_cxns = [];
let cloner_cxns = [];
let monster_coords = [];
let error_found_wires = false;
// TODO i could be a little kinder and support, say, items on terrain; do those work in mscc? tw lynx?
for (let [i, cell] of stored_level.linear_cells.entries()) {
let [x, y] = stored_level.scalar_to_coords(i);
let actor = null;
let other = null;
for (let tile of cell) {
if (! tile)
continue;
if (tile.wire_directions || tile.wire_tunnel_directions) {
error_found_wires = true;
}
if (tile.type.layer === LAYERS.actor) {
actor = tile;
}
else if (tile.type.name === 'floor') {
// This is the default anyway, so don't count it against the number of tiles
continue;
}
else if (other) {
errors.push(`A cell can only contain one static tile, but cell (${x}, ${y}) has both ${other.type.name} and ${tile.type.name}`);
}
else {
other = tile;
}
if (tile.type.is_monster) {
monster_coords.push(x, y);
}
}
let actor_byte = null;
let other_byte = null;
if (actor) {
let rev_spec = REVERSE_TILE_ENCODING[actor.type.name];
if (rev_spec) {
// Special case: dirt blocks only have a direction when on a cloner
if (actor.type.name === 'dirt_block' && ! (other && other.type.name === 'cloner')) {
actor_byte = rev_spec['all'];
}
else {
actor_byte = rev_spec[actor.direction];
}
}
else {
errors.push(`Can't encode tile: ${actor.type.name}`);
}
}
if (other) {
let rev_spec = REVERSE_TILE_ENCODING[other.type.name];
if (rev_spec) {
// Special case: thin walls only come in one of a few configurations
if (other.type.name === 'thin_walls') {
if (other.edges in rev_spec) {
other_byte = rev_spec[other.edges];
}
else {
errors.push(`Thin walls may only have one edge, or be a lower-right corner`);
}
}
else {
other_byte = rev_spec['all'];
}
if (other.type.name === 'hint') {
if (hint_text === null) {
hint_text = other.hint_text;
}
else if (hint_text !== other.hint_text) {
errors.push(`All hints must contain the same text`);
}
}
let cxn_target;
// FIXME one begins to wonder if the lady doth repeat herself
if (other.type.name === 'button_red') {
cxn_target = 'cloner';
}
else if (other.type.name === 'button_brown') {
cxn_target = 'trap';
}
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));
}
else {
// Traps have an extra zero!
trap_cxns.push(x, y, ...stored_level.scalar_to_coords(dest), 0);
}
}
}
}
else {
errors.push(`Can't encode tile: ${other.type.name}`);
}
}
if (other_byte === null) {
other_byte = 0x00; // floor
}
if (actor_byte === null) {
top_layer.push(other_byte);
bottom_layer.push(0x00);
}
else {
top_layer.push(actor_byte);
bottom_layer.push(other_byte);
}
}
if (error_found_wires) {
errors.push(`Wires are not supported`);
}
// TODO RLE
let top_layer_bytes = top_layer;
let bottom_layer_bytes = bottom_layer;
// Assemble metadata fields. You'd think this would deserve a little wrapper like I have for
// the C2M sections, but you're wrong!
let metadata_blocks = [];
let metadata_length = 0;
function add_block(type, contents) {
let len = 2 + contents.byteLength;
let bytes = new Uint8Array(len);
// TODO this copy is annoying
bytes[0] = type;
bytes[1] = contents.byteLength;
bytes.set(new Uint8Array(contents), 2);
metadata_blocks.push(bytes);
metadata_length += len;
}
// Level name
// 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 encode_connections(cxns) {
let words = new ArrayBuffer(cxns.length * 2);
let view = new DataView(words);
for (let [i, val] of cxns.entries()) {
view.setUint16(i * 2, val, true);
}
return words;
}
if (trap_cxns.length > 0) {
add_block(4, encode_connections(trap_cxns));
}
if (cloner_cxns.length > 0) {
add_block(5, encode_connections(cloner_cxns));
}
// Password
// TODO support this for real lol
add_block(6, util.bytestring_to_buffer("XXXX\0"));
// Hint
// TODO tile world seems to do latin-1 (just sort of, inherently); this will do modulo on
// anything outside it (yyyyikes!), probably should sub with ? or something
if (hint_text !== null) {
add_block(7, util.bytestring_to_buffer(hint_text.substring(0, 127) + "\0"));
}
// EXTENSION: Author's name, if present
if (stored_level.author) {
add_block(9, util.bytestring_to_buffer(stored_level.author.substring(0, 255) + "\0"));
}
// Monster positions (dumb as hell and only used in MS mode)
if (monster_coords.length > 0) {
if (monster_coords.length > 256) {
errors.push(`Level has ${monster_coords.length >> 1} monsters, but MS only supports up to 128`);
monster_coords.length = 256;
}
add_block(10, new Uint8Array(monster_coords).buffer);
}
if (errors.length > 0) {
throw new CCLEncodingErrors(errors);
}
// OK, almost done, serialize for real
let level_length = (
10 + // level header
2 + top_layer_bytes.length +
2 + bottom_layer_bytes.length +
2 + metadata_length
);
let total_length = (
6 + // game header
level_length
);
let ret = new ArrayBuffer(total_length);
let array = new Uint8Array(ret);
let view = new DataView(ret);
view.setUint32(0, magic, true);
view.setUint16(4, 1, true); // level count, teehee
let p = 6;
view.setUint16(p, level_length - 2, true); // doesn't include this field
view.setUint16(p + 2, 1, true); // level number
view.setUint16(p + 4, stored_level.time_limit, true);
view.setUint16(p + 6, stored_level.chips_required || 0, true); // FIXME
view.setUint16(p + 8, 1, true); // always 1? indicates compressed map data?
p += 10;
// Map data
view.setUint16(p, top_layer_bytes.length, true);
array.set(new Uint8Array(top_layer_bytes), p + 2);
p += 2 + top_layer_bytes.length;
view.setUint16(p, bottom_layer_bytes.length, true);
array.set(new Uint8Array(bottom_layer_bytes), p + 2);
p += 2 + bottom_layer_bytes.length;
// Metadata
view.setUint16(p, metadata_length, true);
p += 2;
for (let block of metadata_blocks) {
array.set(block, p);
p += block.byteLength;
}
if (p !== total_length) {
console.error("Something has gone very awry:", total_length, p);
}
return ret;
}

164
js/format-tws.js Normal file
View File

@ -0,0 +1,164 @@
import { DIRECTIONS, INPUT_BITS } from './defs.js';
import * as format_base from './format-base.js';
const TW_DIRECTION_TO_INPUT_BITS = [
INPUT_BITS.up,
INPUT_BITS.left,
INPUT_BITS.down,
INPUT_BITS.right,
INPUT_BITS.up | INPUT_BITS.left,
INPUT_BITS.down | INPUT_BITS.left,
INPUT_BITS.up | INPUT_BITS.right,
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) {
buf = bytes.buffer;
}
else {
buf = bytes;
bytes = new Uint8Array(buf);
}
let view = new DataView(buf);
let magic = view.getUint32(0, true);
if (magic !== 0x999b3335)
return;
// 1 for lynx, 2 for ms; also extended to 3 for cc2, 4 for ll
let ruleset = bytes[4];
let extra_bytes = bytes[7];
let ret = {
ruleset: ruleset,
levels: [],
};
let p = 8 + extra_bytes;
let is_first = true;
while (p < buf.byteLength) {
let len = view.getUint32(p, true);
p += 4;
if (len === 0xffffffff)
break;
if (len === 0) {
// Empty, do nothing
}
else if (len < 6) {
// This should never happen
// TODO gripe?
}
else if (bytes[p] === 0 && bytes[p + 1] === 0 && bytes[p + 2] === 0 &&
bytes[p + 3] === 0 && bytes[p + 5] === 0 && bytes[p + 6] === 0)
{
// This record is special and contains the name of the set; it's optional but, if present, must be first
if (! is_first) {
// TODO gripe?
}
}
else if (len === 6) {
// Short record; password only, no replay
}
else {
// Long record
let number = view.getUint16(p, true);
// 2-5: password, don't care
// 6: flags, always zero
let initial_state = bytes[p + 7];
let step_parity = initial_state >> 3;
let initial_rff = ['north', 'west', 'south', 'east'][initial_state & 0x7];
// In CC2 replays, the initial RFF direction is the one you'll actually start with;
// however, in Lynx, the direction is rotated BEFORE it takes effect, so to compensate
// we have to rotate this once ahead of time
initial_rff = DIRECTIONS[initial_rff].right;
let initial_rng = view.getUint32(p + 8, true);
let total_duration = view.getUint32(p + 12, true);
// TODO split this off though
let inputs = [];
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. 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];
let input2 = TW_DIRECTION_TO_INPUT_BITS[(val >> 4) & 0x3];
let input3 = TW_DIRECTION_TO_INPUT_BITS[(val >> 6) & 0x3];
inputs.push(
0, 0, 0, input1,
0, 0, 0, input2,
0, 0, 0, input3,
);
}
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];
q += 1;
}
else if (fmt === 2) {
val = view.getUint16(q, true);
q += 2;
}
else {
val = view.getUint32(q, true);
q += 4;
}
let input = TW_DIRECTION_TO_INPUT_BITS[(val >> 2) & 0x7];
let duration = val >> 5;
for (let i = 0; i < duration; i++) {
inputs.push(0);
}
inputs.push(input);
}
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;
}
}
ret.levels[number - 1] = new format_base.Replay(initial_rff, 0, inputs, step_parity, initial_rng);
}
is_first = false;
p += len;
}
return ret;
}

View File

@ -1,47 +0,0 @@
export function string_from_buffer_ascii(buf) {
return String.fromCharCode.apply(null, new Uint8Array(buf));
}
export class StoredCell extends Array {
}
export class StoredLevel {
constructor(number) {
this.number = number; // one-based
this.title = '';
this.password = null;
this.hint = '';
this.chips_required = 0;
this.time_limit = 0;
this.viewport_size = 9;
this.extra_chunks = [];
this.use_cc1_boots = false;
this.size_x = 0;
this.size_y = 0;
this.linear_cells = [];
// Maps of button positions to trap/cloner positions, as scalar indexes
// in the linear cell list
this.custom_trap_wiring = {};
this.custom_cloner_wiring = {};
}
scalar_to_coords(n) {
return [n % this.size_x, Math.floor(n / this.size_x)];
}
coords_to_scalar(x, y) {
return x + y * this.size_x;
}
check() {
}
}
export class StoredGame {
constructor(identifier) {
this.identifier = identifier;
this.levels = [];
}
}

3429
js/game.js

File diff suppressed because it is too large Load Diff

592
js/headless/bulktest.mjs Normal file
View File

@ -0,0 +1,592 @@
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 { LocalDirectorySource } from './lib.js';
// TODO arguments:
// - custom pack to test, possibly its solutions, possibly its ruleset (or default to steam-strict/lynx)
// - filter existing packs
// - verbose: ?
// - quiet: hide failure reasons
// - support for xfails somehow?
// TODO use this for a test suite
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: "-",
},
'no-replay': {
color: "\x1b[90m",
symbol: "0",
},
success: {
color: "\x1b[92m",
symbol: ".",
},
early: {
color: "\x1b[96m",
symbol: "?",
},
failure: {
color: "\x1b[91m",
symbol: "#",
},
'short': {
color: "\x1b[93m",
symbol: "#",
},
error: {
color: "\x1b[95m",
symbol: "X",
},
};
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`);
}
if (dy > 0) {
stdout.write(`\x1b[${dy}B`);
}
else if (dy < 0) {
stdout.write(`\x1b[${-dy}A`);
}
}
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)));
}
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);
}
if (level.tic_counter % 20 === 1) {
// XXX
/*
if (handle.cancel) {
return make_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;
}
*/
}
}
}
// 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,
};
}
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) {
// FIXME copied wholesale from Splash.search_multi_source; need a real filesystem + searching api!
// TODO not entiiirely kosher, but not sure if we should have an api for this or what
if (source._loaded_promise) {
await source._loaded_promise;
}
let paths = Object.keys(source.files);
// TODO should handle having multiple candidates, but this is good enough for now
paths.sort((a, b) => a.length - b.length);
for (let path of paths) {
let m = path.match(/[.]([^./]+)$/);
if (! m)
continue;
let ext = m[1];
// TODO this can't load an individual c2m, hmmm
if (ext === 'c2g') {
let buf = await source.get(path);
//await this.conductor.parse_and_load_game(buf, source, path);
// FIXME and this is from parse_and_load_game!!
let dir;
if (! path.match(/[/]/)) {
dir = '';
}
else {
dir = path.replace(/[/][^/]+$/, '');
}
return await format_c2g.parse_game(buf, source, dir);
}
}
// 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 = `\
Usage: bulktest.mjs [OPTION]... [FILE]...
Runs replays for the given level packs and report results.
With no FILE given, default to the built-in copy of CC2LP1.
Arguments may be repeated, and apply to any subsequent pack, so different packs
may be run with different compat modes.
-c compatibility mode; one of
lexy (default), steam, steam-strict, lynx, ms
-r path to a file containing replays; for CCL/DAT packs, which
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'
-h, --help ignore other arguments and show this message
Supports the same filetypes as Lexy's Labyrinth: DAT/CCL, C2M, or a directory
containing a C2G.
`;
class ArgParseError extends Error {}
function parse_level_range(string) {
if (string === 'all') {
return null;
}
let res = new Set;
let parts = string.split(/,/);
for (let part of parts) {
let endpoints = part.match(/^(\d+)(?:-(\d+))?$/);
if (endpoints === null)
throw new ArgParseError(`Bad syntax in level range: ${part}`);
let a = parseInt(endpoints[1], 10);
let b = endpoints[2] === undefined ? a : parseInt(endpoints[2], 10);
if (a > b)
throw new ArgParseError(`Backwards span in level range: ${part}`);
for (let n = a; n <= b; n++) {
res.add(n);
}
}
return res;
}
function parse_args() {
// Parse arguments
let test_template = {
ruleset: 'lexy',
solutions_path: null,
level_filter: null,
};
let tests = [];
try {
let i;
let next_arg = () => {
i += 1;
if (i >= argv.length)
throw new ArgParseError(`Missing argument after ${argv[i - 1]}`);
return argv[i];
};
for (i = 2; i < argv.length; i++) {
let arg = argv[i];
if (arg === '-h' || arg === '--help') {
stdout.write(USAGE);
exit(0);
}
if (arg === '-c') {
let ruleset = next_arg();
if (['lexy', 'steam', 'steam-strict', 'lynx', 'ms'].indexOf(ruleset) === -1)
throw new ArgParseError(`Unrecognized compat mode: ${ruleset}`);
test_template.ruleset = ruleset;
}
else if (arg === '-r') {
test_template.solutions_path = next_arg();
}
else if (arg === '-l') {
test_template.level_filter = parse_level_range(next_arg());
}
else if (arg === '-f') {
tests.push({ pack_path: next_arg(), ...test_template });
}
else {
tests.push({ pack_path: arg, ...test_template });
}
}
}
catch (e) {
if (e instanceof ArgParseError) {
stderr.write(e.message);
stderr.write("\n");
exit(2);
}
}
if (tests.length === 0) {
tests.push({ pack_path: 'levels/CC2LP1.zip', ...test_template });
}
return tests;
}
async function main() {
let tests = parse_args();
let overall = {
num_passed: 0,
num_missing: 0,
num_failed: 0,
time_elapsed: 0,
time_simulated: 0,
};
for (let testdef of tests) {
let result = await test_pack(testdef);
for (let key of Object.keys(overall)) {
overall[key] += result[key];
}
}
let num_levels = overall.num_passed + overall.num_failed + overall.num_missing;
stdout.write("\n");
stdout.write(`${overall.num_passed}/${num_levels} = ${(overall.num_passed / num_levels * 100).toFixed(1)}% passed (${overall.num_failed} failed, ${overall.num_missing} missing replay)\n`);
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`);
}
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();

304
js/main-base.js Normal file
View File

@ -0,0 +1,304 @@
import { mk, mk_svg } from './util.js';
// Superclass for the main display modes: the player, the editor, and the splash screen
export class PrimaryView {
constructor(conductor, root) {
this.conductor = conductor;
this.root = root;
this.active = false;
this._done_setup = false;
}
setup() {}
activate() {
this.root.removeAttribute('hidden');
this.active = true;
if (! this._done_setup) {
this.setup();
this._done_setup = true;
}
}
deactivate() {
this.root.setAttribute('hidden', '');
this.active = false;
}
reload_options(options) {}
}
// Stackable modal overlay of some kind, usually a dialog
export class Overlay {
constructor(conductor, root) {
this.conductor = conductor;
this.root = root;
// Make the dialog itself focusable; this makes a lot of stuff easier, like ensuring that
// pressing Esc always has a viable target
this.root.tabIndex = 0;
// Don't propagate clicks on the root element, so they won't trigger a parent overlay's
// automatic dismissal
this.root.addEventListener('click', ev => {
ev.stopPropagation();
});
// Don't propagate keys, either. This is only a partial solution (for when something within
// the dialog has focus), but open() adds another handler to block keys more aggressively
this.root.addEventListener('keydown', ev => {
ev.stopPropagation();
if (ev.key === 'Escape') {
this.close();
}
});
}
open() {
if (this.root.isConnected) {
this.close();
}
if (this.conductor.player.state === 'playing') {
this.conductor.player.set_state('paused');
}
let overlay = mk('div.overlay', this.root);
document.body.append(overlay);
// Remove the overlay when clicking outside the element
overlay.addEventListener('click', ev => {
this.close();
});
// Start with the overlay itself focused
this.root.focus();
// While this dialog is open, keys should not reach the rest of the document, and you should
// not be able to tab your way out of it. This is a rough implementation of that.
// Note that focusin bubbles, but focus doesn't. Also, focusin happens /just before/ an
// element receives focus, not afterwards, but that doesn't seem to affect this.
this.focusin_handler = ev => {
// If we're no longer visible at all, remove this event handler
if (! this.root.isConnected) {
this._remove_global_event_handlers();
return;
}
// If we're not the topmost overlay, do nothing
if (this.root.parentNode.nextElementSibling)
return;
// No problem if the focus is within the dialog, OR on the root <html> element
if (ev.target === document.documentElement || this.root.contains(ev.target)) {
this.last_focused = ev.target;
return;
}
// Otherwise, focus is trying to escape! Put a stop to that.
// Focus was probably moved with tab or shift-tab. We should be the last element in the
// document, so tabbing off the end of us should go to browser UI. Shift-tabbing back
// beyond the start of a document usually goes to the root (and after that, browser UI
// again). Thus, there are only two common cases here: if the last valid focus was on
// the document root, they must be tabbing forwards, so focus our first element; if the
// last valid focus was within us, they must be tabbing backwards, so focus the root.
if (this.last_focused === document.documentElement) {
this.root.focus();
this.last_focused = this.root;
}
else {
document.documentElement.focus();
this.last_focused = document.documentElement;
}
};
window.addEventListener('focusin', this.focusin_handler);
// Block any keypresses attempting to go to an element outside the dialog
this.keydown_handler = ev => {
// If we're no longer visible at all, remove this event handler
if (! this.root.isConnected) {
this._remove_global_event_handlers();
return;
}
// If we're not the topmost overlay, do nothing
if (this.root.parentNode.nextElementSibling)
return;
// Note that if the target is the window itself, contains() will explode
if (! (ev.target instanceof Node && this.root.contains(ev.target))) {
ev.stopPropagation();
}
};
// Use capture, which runs before any other event handler
window.addEventListener('keydown', this.keydown_handler, true);
// Block mouse movement as well
overlay.addEventListener('mousemove', ev => {
ev.stopPropagation();
});
return overlay;
}
_remove_global_event_handlers() {
window.removeEventListener('focusin', this.focusin_handler);
window.removeEventListener('keydown', this.keydown_handler, true);
}
close() {
this._remove_global_event_handlers();
this.root.closest('.overlay').remove();
if (document.activeElement) {
// The active element is almost certainly either the dialog or a control within it,
// which is useless as a focus target, so blur it and let the page have focus
document.activeElement.blur();
}
}
}
// Overlay styled like a popup of some sort
export class TransientOverlay extends Overlay {
open() {
// TODO i don't like how vaguely arbitrary this feels.
let overlay = super.open();
overlay.classList.add('--transient');
return overlay;
}
}
export class MenuOverlay extends TransientOverlay {
constructor(conductor, items, make_label, onclick) {
super(conductor, mk('ol.popup-menu'));
for (let [i, item] of items.entries()) {
this.root.append(mk('li', {'data-index': i}, make_label(item)));
}
this.root.addEventListener('click', ev => {
let li = ev.target.closest('li');
if (! li || ! this.root.contains(li))
return;
let i = parseInt(li.getAttribute('data-index'), 10);
let item = items[i];
onclick(item);
this.close();
});
}
open(relto) {
super.open();
let anchor = relto.getBoundingClientRect();
let rect = this.root.getBoundingClientRect();
// Prefer left anchoring, but use right if that would go off the screen
if (anchor.left + rect.width > document.body.clientWidth) {
this.root.style.right = `${document.body.clientWidth - anchor.right}px`;
}
else {
this.root.style.left = `${anchor.left}px`;
}
// Open vertically in whichever direction has more space (with a slight bias towards opening
// downwards). If we would then run off the screen, also set the other anchor to constrain
// the height.
let top_space = anchor.top - 0;
let bottom_space = document.body.clientHeight - anchor.bottom;
if (top_space > bottom_space) {
this.root.style.bottom = `${document.body.clientHeight - anchor.top}px`;
if (rect.height > top_space) {
this.root.style.top = `${0}px`;
}
}
else {
this.root.style.top = `${anchor.bottom}px`;
if (rect.height > bottom_space) {
this.root.style.bottom = `${0}px`;
}
}
}
}
// Overlay styled like a dialog box
export class DialogOverlay extends Overlay {
constructor(conductor) {
super(conductor, mk('form.dialog'));
this.root.append(
this.header = mk('header'),
this.main = mk('section'),
this.footer = mk('footer'),
);
}
set_title(title) {
this.header.textContent = '';
this.header.append(mk('h1', {}, title));
}
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;
}
add_button_gap() {
this.footer.append(mk('div.-spacer'));
}
}
// Informational popup dialog
export class AlertOverlay extends DialogOverlay {
constructor(conductor, message, title = "heads up") {
super(conductor);
this.set_title(title);
this.main.append(mk('p', {}, message));
this.add_button("a'ight", () => {
this.close();
}, true);
}
}
// Yes/no popup dialog
export class ConfirmOverlay extends DialogOverlay {
constructor(conductor, message, what) {
super(conductor);
this.set_title("just checking");
this.main.append(mk('p', {}, message));
this.add_button("yep", ev => {
this.close();
what();
}, true);
this.add_button("nope", ev => {
this.close();
});
}
}
export function flash_button(button) {
button.classList.add('--button-glow-ok');
window.setTimeout(() => {
button.classList.add('--button-glow');
button.classList.remove('--button-glow-ok');
}, 10);
window.setTimeout(() => {
button.classList.remove('--button-glow');
}, 500);
}
export function svg_icon(name) {
return mk_svg(
'svg.svg-icon',
{viewBox: '0 0 16 16'},
mk_svg('use', {href: `#svg-icon-${name}`}));
}
export function load_json_from_storage(key) {
return JSON.parse(window.localStorage.getItem(key));
}
export function save_json_to_storage(key, value) {
window.localStorage.setItem(key, JSON.stringify(value));
}

4979
js/main.js

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,36 @@
import { DIRECTIONS } from './defs.js';
import { mk } from './util.js';
import { DIRECTIONS, LAYERS } from './defs.js';
import * as util from './util.js';
import { DrawPacket } from './tileset.js';
import TILE_TYPES from './tiletypes.js';
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;
this.y = 0;
// Offset within the cell, for actors in motion
this.offsetx = 0;
this.offsety = 0;
}
blit(tx, ty, mx = 0, my = 0, mw = 1, mh = mw, mdx = mx, mdy = my) {
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.tileset.blit_to_canvas(this.ctx,
tx + mx, ty + my,
this.x + mdx, this.y + mdy,
mw, mh);
}
}
export class CanvasRenderer {
constructor(tileset, fixed_size = null) {
this.tileset = tileset;
@ -11,7 +40,6 @@ export class CanvasRenderer {
// to do so, but then we wouldn't make a canvas so it couldn't be
// hooked, yadda yadda
if (fixed_size) {
this.viewport_is_fixed = true;
this.viewport_size_x = fixed_size;
this.viewport_size_y = fixed_size;
}
@ -19,13 +47,68 @@ export class CanvasRenderer {
this.viewport_size_x = 9;
this.viewport_size_y = 9;
}
this.canvas = mk('canvas', {width: tileset.size_x * this.viewport_size_x, height: tileset.size_y * this.viewport_size_y});
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
this.canvas = this.constructor.make_canvas(
tileset.size_x * this.viewport_size_x,
tileset.size_y * this.viewport_size_y);
if (this.canvas.style) {
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
this.canvas.style.setProperty('--tile-width', `${tileset.size_x}px`);
this.canvas.style.setProperty('--tile-height', `${tileset.size_y}px`);
}
this.ctx = this.canvas.getContext('2d');
this.viewport_x = 0;
this.viewport_y = 0;
this.viewport_dirty = false;
this.show_actor_bboxes = false;
this.show_actor_order = false;
this.show_facing = false;
this.use_rewind_effect = false;
this.perception = 'normal'; // normal, xray, editor, palette
this.hide_logic = false;
this.update_rate = 3;
this.use_cc2_anim_speed = false;
this.active_player = null;
}
// This is here so command-line Node stuff can swap it out for the canvas package
static make_canvas(w, 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) {
@ -33,6 +116,34 @@ export class CanvasRenderer {
// TODO update viewport size... or maybe Game should do that since you might be cheating
}
set_active_player(actor) {
this.active_player = actor;
}
// Change the viewport size. DOES NOT take effect until the next redraw!
set_viewport_size(x, y) {
this.viewport_size_x = x;
this.viewport_size_y = y;
this.viewport_dirty = true;
}
set_tileset(tileset) {
this.tileset = tileset;
this.viewport_dirty = true;
}
get_cell_rect(x, y) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let tile_w = scale_x * this.tileset.size_x;
let tile_h = scale_y * this.tileset.size_y;
return new DOMRect(
rect.x + (x - this.viewport_x) * tile_w,
rect.y + (y - this.viewport_y) * tile_h,
tile_w, tile_h);
}
cell_coords_from_event(ev) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
@ -42,23 +153,66 @@ 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);
point_to_cell_coords(client_x, client_y) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let x = Math.floor((client_x - rect.x) / scale_x / this.tileset.size_x + this.viewport_x);
let y = Math.floor((client_y - rect.y) / scale_y / this.tileset.size_y + this.viewport_y);
return [x, y];
}
draw(tic_offset = 0) {
real_cell_coords_from_event(ev) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let x = (ev.clientX - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
let y = (ev.clientY - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
return [x, y];
}
point_to_real_cell_coords(client_x, client_y) {
let rect = this.canvas.getBoundingClientRect();
let scale_x = rect.width / this.canvas.width;
let scale_y = rect.height / this.canvas.height;
let x = (client_x - rect.x) / scale_x / this.tileset.size_x + this.viewport_x;
let y = (client_y - rect.y) / scale_y / this.tileset.size_y + this.viewport_y;
return [x, y];
}
_adjust_viewport_if_dirty() {
if (! this.viewport_dirty)
return;
this.viewport_dirty = false;
this.canvas.width = this.tileset.size_x * this.viewport_size_x;
this.canvas.height = this.tileset.size_y * this.viewport_size_y;
if (this.canvas.style) {
this.canvas.style.setProperty('--viewport-width', this.viewport_size_x);
this.canvas.style.setProperty('--viewport-height', this.viewport_size_y);
this.canvas.style.setProperty('--tile-width', `${this.tileset.size_x}px`);
this.canvas.style.setProperty('--tile-height', `${this.tileset.size_y}px`);
}
}
draw(update_progress = 0) {
if (! this.level) {
console.warn("CanvasRenderer.draw: No level to render");
return;
}
let tic = (this.level.tic_counter ?? 0) + tic_offset;
this._adjust_viewport_if_dirty();
// Compute the effective current time. Note that this might come out negative before the
// 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 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;
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
@ -67,17 +221,22 @@ export class CanvasRenderer {
// TODO what about levels smaller than the viewport...? shrink the canvas in set_level?
let xmargin = (this.viewport_size_x - 1) / 2;
let ymargin = (this.viewport_size_y - 1) / 2;
let px, py;
// FIXME editor vs player
if (this.level.player) {
[px, py] = this.level.player.visual_position(tic_offset);
}
else {
[px, py] = [0, 0];
}
let [px, py] = this.level.player.visual_position(update_progress, packet.update_rate);
// Figure out where to start drawing
let x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, px - xmargin));
let y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, py - ymargin));
// TODO support overlapping regions better
let x0 = px - xmargin;
let y0 = py - ymargin;
for (let region of this.level.stored_level.camera_regions) {
if (px >= region.left && px < region.right &&
py >= region.top && py < region.bottom)
{
x0 = Math.max(region.left, Math.min(region.right - this.viewport_size_x, x0));
y0 = Math.max(region.top, Math.min(region.bottom - this.viewport_size_y, y0));
}
}
// Always keep us within the map bounds
x0 = Math.max(0, Math.min(this.level.size_x - this.viewport_size_x, x0));
y0 = Math.max(0, Math.min(this.level.size_y - this.viewport_size_y, y0));
// Round to the pixel grid
x0 = Math.floor(x0 * tw + 0.5) / tw;
y0 = Math.floor(y0 * th + 0.5) / th;
@ -86,58 +245,143 @@ export class CanvasRenderer {
// The viewport might not be aligned to the grid, so split off any fractional part.
let xf0 = Math.floor(x0);
let yf0 = Math.floor(y0);
// Note that when the viewport is exactly aligned to the grid, we need to draw the cells
// just outside of it, or we'll miss objects partway through crossing the border
if (xf0 === x0 && xf0 > 0) {
// We need to draw one cell beyond the viewport, or we'll miss objects partway through
// crossing the border moving away from us
if (xf0 > 0) {
xf0 -= 1;
}
if (yf0 === y0 && yf0 > 0) {
if (yf0 > 0) {
yf0 -= 1;
}
// Find where to stop drawing. As with above, if we're aligned to the grid, we need to
// include the tiles just outside it, so we allow this fencepost problem to fly
let x1 = Math.min(this.level.size_x - 1, Math.ceil(x0 + this.viewport_size_x));
let y1 = Math.min(this.level.size_y - 1, Math.ceil(y0 + this.viewport_size_y));
// Draw one layer at a time, so animated objects aren't overdrawn by
// Tiles in motion (i.e., actors) don't want to be overdrawn by neighboring tiles' terrain,
// so draw in three passes: everything below actors, actors, and everything above actors
// neighboring terrain
// XXX layer count hardcoded here
for (let layer = 0; layer < 4; layer++) {
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
for (let tile of this.level.cells[y][x]) {
if (tile.type.draw_layer !== layer)
continue;
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y);
for (let layer = 0; layer < LAYERS.actor; layer++) {
let tile = cell[layer];
if (! tile)
continue;
if (tile.type.is_actor &&
// FIXME kind of a hack for the editor, which uses bare tile objects
tile.visual_position)
{
// Handle smooth scrolling
let [vx, vy] = tile.visual_position(tic_offset);
// Round this to the pixel grid too!
vx = Math.floor(vx * tw + 0.5) / tw;
vy = Math.floor(vy * th + 0.5) / th;
this.tileset.draw(tile, tic, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
this.blit(this.ctx, sx, sy, vx - x0 + dx, vy - y0 + dy, w, h));
}
else {
// Non-actors can't move
this.tileset.draw(tile, tic, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
this.blit(this.ctx, sx, sy, x - x0 + dx, y - y0 + dy, w, h));
}
}
packet.x = x - x0;
packet.y = y - y0;
this.tileset.draw(tile, packet);
}
}
}
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y);
let actor = cell[LAYERS.actor];
if (! actor)
continue;
// Handle smooth scrolling
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
// Round this to the pixel grid too!
vx = Math.floor(vx * tw + 0.5) / tw;
vy = Math.floor(vy * th + 0.5) / th;
// For blocks, perception only applies if there's something of interest underneath
if (this.perception !== 'normal' && actor.type.is_block &&
! cell.some(t => t && t.type.layer < LAYERS.actor && ! (
t.type.name === 'floor' && (t.wire_directions | t.wire_tunnel_directions) === 0)))
{
packet.perception = 'normal';
}
else {
packet.perception = this.perception;
}
packet.x = x - x0;
packet.y = y - y0;
packet.offsetx = vx - x;
packet.offsety = vy - y;
// Draw the active player background
if (actor === this.active_player) {
this.tileset.draw_type('#active-player-background', null, packet);
}
this.tileset.draw(actor, packet);
// If they killed the player, indicate as such. The indicator has an arrow at the
// bottom; align that about 3/4 up the killer
if (actor.is_killer && '#killer-indicator' in this.tileset.layout) {
this.tileset.draw_type('#killer-indicator', null, packet);
}
}
}
packet.perception = this.perception;
packet.offsetx = 0;
packet.offsety = 0;
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let cell = this.level.cell(x, y);
for (let layer = LAYERS.actor + 1; layer < LAYERS.MAX; layer++) {
let tile = cell[layer];
if (! tile)
continue;
packet.x = x - x0;
packet.y = y - y0;
this.tileset.draw(tile, packet);
}
}
}
if (this.use_rewind_effect) {
this.draw_rewind_effect(tic);
this.draw_rewind_effect(packet.clock);
}
// Debug overlays
if (this.show_actor_bboxes) {
this.ctx.fillStyle = '#f004';
for (let x = xf0; x <= x1; x++) {
for (let y = yf0; y <= y1; y++) {
let actor = this.level.cell(x, y).get_actor();
if (! actor)
continue;
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
// Don't round to the pixel grid; we want to know if the bbox is misaligned!
this.ctx.fillRect((vx - x0) * tw, (vy - y0) * th, 1 * tw, 1 * th);
}
}
}
if (this.show_actor_order) {
this.ctx.fillStyle = '#fff';
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 3;
this.ctx.font = '16px monospace';
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
for (let [n, actor] of this.level.actors.entries()) {
let cell = actor.cell;
if (! cell)
continue;
if (cell.x < xf0 || cell.x > x1 || cell.y < yf0 || cell.y > y1)
continue;
let [vx, vy] = actor.visual_position(update_progress, packet.update_rate);
let x = (vx + 0.5 - x0) * tw;
let y = (vy + 0.5 - y0) * th;
let label = String(this.level.actors.length - 1 - n);
this.ctx.strokeText(label, x, y);
this.ctx.fillText(label, x, y);
}
}
}
draw_rewind_effect(tic) {
draw_rewind_effect(clock) {
// Shift several rows over in a recurring pattern, like a VHS, whatever that is
let rewind_start = tic / 20 % 1;
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++) {
@ -145,15 +389,105 @@ 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),
// assuming nothing is moving.
draw_static_region(x0, y0, x1, y1, destx = x0, desty = y0) {
this.draw_static_generic({x0, y0, x1, y1, destx, desty});
}
// Most generic possible form of drawing a static region; mainly useful if you want to use a
// different canvas or draw a custom block of cells
// TODO does this actually need any state at all? could it just be, dare i ask, a function?
draw_static_generic({
x0, y0, x1, y1, destx = x0, desty = y0, cells = null, width = null,
ctx = this.ctx, perception = this.perception, show_facing = this.show_facing,
}) {
if (ctx === this.ctx) {
this._adjust_viewport_if_dirty();
}
width = width ?? this.level.size_x;
cells = cells ?? this.level.linear_cells;
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];
if (! cell)
continue;
let seen_anything_interesting;
for (let tile of cell) {
if (! tile)
continue;
// For actors (i.e., blocks), perception only applies if there's something
// of potential interest underneath
if (perception !== 'normal' && tile.type.is_block && ! seen_anything_interesting) {
packet.perception = 'normal';
}
else {
packet.perception = perception;
}
if (tile.type.layer < LAYERS.actor && ! (
tile.type.name === 'floor' && (tile.wire_directions | tile.wire_tunnel_directions) === 0))
{
seen_anything_interesting = true;
}
// Don't draw facing arrows atop blocks, unless they're on a cloner or trap
// where it matters (it's distracting in large clumps and makes it hard to see
// frame arrows)
packet.show_facing = show_facing;
if (show_facing && tile.type.is_block) {
let terrain_name = cell[LAYERS.terrain].type.name;
if (! (terrain_name === 'cloner' || terrain_name === 'trap')) {
packet.show_facing = false;
}
}
packet.x = destx + x - x0;
packet.y = desty + y - y0;
this.tileset.draw(tile, packet);
}
}
}
}
create_tile_type_canvas(name) {
let canvas = mk('canvas', {width: this.tileset.size_x, height: this.tileset.size_y});
// TODO one wonders why this operates on a separate canvas and we don't just make new renderers
// or something, or maybe make this a tileset method
draw_single_tile_type(name, tile = null, canvas = null, x = 0, y = 0) {
if (! canvas) {
canvas = this.constructor.make_canvas(this.tileset.size_x, this.tileset.size_y);
}
let ctx = canvas.getContext('2d');
this.tileset.draw_type(name, null, 0, (sx, sy, dx = 0, dy = 0, w = 1, h = w) =>
this.blit(ctx, sx, sy, dx, dy, w, h));
// Individual tile types always reveal what they are
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);
return canvas;
}
}

View File

@ -43,7 +43,7 @@ export default [{
title: "conundrum",
author: "fluffy",
url: 'https://beesbuzz.biz/',
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00bkzgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
beepbox: 'https://www.beepbox.co/#8n31sbk0l00e0rt2mm0a7g0rj07i0r1o3210T1v1L4u01q3d7fay3z6C0c1A5F4B0V1Q0248Pac74E0085T1v1L4u01q1d1f4y4z9C0c1A1F0B0V1Q200ePd593E0787T5v1L4u05q3d0f1y4z0C2c0h0H_--D-quSRIAJJST4v1L4u04q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4zgid18Q4zgid0000000018Nwz55p88Nhnh5t000000ichgR5zkicg004hcx4O4jhkohd14z4isp25PFJvy1wqY58llp2GhKFLh_wFLh-czArdWfRuKrdQ_p13c5FKfZ_tRBPjub_aq_TYtjhZgtBdJlldtlldtlleKGGCKGGCK1wqoiGGiOdI3j1RlkOJBtdZllmG1wqoiGGCK1JFxaEm1STTQMBlldtlldtlJKoHrarqGGqWGGqWGGth7ihT4sChON7a4sIpqPhDkCz9FFOG8YwzOF8Wp8WFOHyfAVARQVB4ughVkAtcAtkVl97Op8Wd8Td6nl9EOaqSqcyIz9UzMbaXbAenShhAPvehhrBRApcVmnlsV5vaGysD9CKsyyLbH8OpOIKGVEGKttV56jdkV55uTnpeldpuptSltdRKpEPpFFOW8YwzOF8Wp8WFOGU0',
path: 'music/conundrum.ogg',
}, {
title: "kinda song",
@ -51,4 +51,29 @@ export default [{
twitter: "glitchedpuppet",
beepbox: 'https://jummbus.bitbucket.io/#j2N07Unnamedn310s0k0l00e0jt2mm0a7g0jj07i0r1O_U0000o3210T1v0wL0OD0Ou01q1d5f6y0z6C1c0A1F2B5V6Q20a0Pe64bE0171T1v0pL0OD0Ou92q1d4f7y2z1C0c2AbF6B6V9Q0490Pb976E0001T1v0pL0OD0Ou94q1d2f7y2z1C0c2A9F5B5V6Q290dPa883E0011T4v0pL0OD0Ouf0q1z6666ji8k8k3jSBKSJJAArriiiiii07JCABrzrrrrrrr00YrkqHrsrrrrjr005zrAqzrjzrrqr1jRjrqGGrrzsrsA099ijrABJJJIAzrrtirqrqjqixzsrAjrqjiqaqqysttAJqjikikrizrHtBJJAzArzrIsRCITKSS099ijrAJS____Qg99habbCAYrDzh00b4x8Qd000lBu7014x4i4Qd3gQlmoh4ia2cz8OcChA4h4y8w01cPhjl0p27hFCLwATnMkCKChW2ZcngbUxcnikQ7sQO_inQ5WCkXyW9jbZ9vlO_lODhdcDjQNtgJ0Gp7IpAukFczR2FyX2frIzQ4zOGOOfpauP9vainRihQPK4tClqoKMnaAzQQnQQnRknXx7pBnK2OFjSO_oELFAzFIOWa8WF8WpHW3nEdv26LgqZcLQQap7Iu6P9j5R2Q2Q2Q2VeRfbEbWGq2-DinQBZttkHUMRWPn9HFAuwzEe3E8W2ehFyUsNncLQThuCnW2_aGydcngbkO_rZdkPjdcSpvx9jbZ3cRZtcO_lipvFSFkO_lN4YlAjBpdp6hahFyWz5OXbWFbWF8YEmChy3wWiehcK0bWaoEIlChw3JHp5K5E5E5w1sPb5P2f9as0LVds0bkbukQni0JFyQ5c0bokRyXrbxqxr2CyWSOSAzw1qxqxrNrxqxrhtwJgJMJJhvmbibikO8J5JwJEJEJtE',
path: 'music/kinda-song.ogg',
}, {
title: "learning has occurred",
author: "jneen",
twitter: "jneen_",
path: 'music/learning-has-occurred.ogg',
}, {
title: "escape on star road",
author: "jneen",
twitter: "jneen_",
path: 'music/escape-on-star-road.ogg',
}, {
title: "inner orbit",
author: "notchris",
twitter: "chrismcgrane",
path: 'music/inner-orbit.ogg',
}, {
title: "canopy",
author: "notchris",
twitter: "chrismcgrane",
path: 'music/canopy.ogg',
}, {
title: "asteroid prairie",
author: "notchris",
twitter: "chrismcgrane",
path: 'music/asteroid-prairie.ogg',
}];

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,42 @@
import * as fflate from './vendor/fflate.js';
// Base class for custom errors
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) {
if (children.length > 0) {
if (!(children[0] instanceof Node) && children[0] !== undefined && typeof(children[0]) !== "string" && typeof(children[0]) !== "number") {
@ -18,17 +53,114 @@ function _mk(el, children) {
export function mk(tag_selector, ...children) {
let [tag, ...classes] = tag_selector.split('.');
let el = document.createElement(tag);
el.classList = classes.join(' ');
if (classes.length > 0) {
el.classList = classes.join(' ');
}
return _mk(el, children);
}
export function mk_button(label, onclick) {
let el = mk('button', {type: 'button'}, label);
el.addEventListener('click', onclick);
return el;
}
export const SVG_NS = 'http://www.w3.org/2000/svg';
export function mk_svg(tag_selector, ...children) {
let [tag, ...classes] = tag_selector.split('.');
let el = document.createElementNS('http://www.w3.org/2000/svg', tag);
el.classList = classes.join(' ');
let el = document.createElementNS(SVG_NS, tag);
if (classes.length > 0) {
el.classList = classes.join(' ');
}
return _mk(el, children);
}
export function trigger_local_download(filename, blob) {
let url = URL.createObjectURL(blob);
// To download a file, um, make an <a> and click it. Not kidding
let a = mk('a', {
href: url,
download: filename,
});
document.body.append(a);
a.click();
// Absolutely no idea when I'm allowed to revoke this, but surely a minute is safe
window.setTimeout(() => {
a.remove();
URL.revokeObjectURL(url);
}, 60 * 1000);
}
export function handle_drop(element, options) {
let dropzone_class = options.dropzone_class ?? null;
let on_drop = options.on_drop;
let require_file = options.require_file ?? false;
let is_valid = ev => {
// TODO this requires files, should make some args for this
if (options.require_file) {
let dt = ev.dataTransfer;
if (! dt || dt.items.length === 0)
return false;
// Only test the first item I guess? If it's a file then they should all be files
if (dt.items[0].kind !== 'file')
return false;
}
return true;
};
let end_drop = () => {
if (dropzone_class !== null) {
element.classList.remove(dropzone_class);
}
};
// TODO should have a filter function for when a drag is valid but i forget which of these
// should have that
element.addEventListener('dragenter', ev => {
if (! is_valid(ev))
return;
ev.stopPropagation();
ev.preventDefault();
if (dropzone_class !== null) {
element.classList.add(dropzone_class);
}
});
element.addEventListener('dragover', ev => {
if (! is_valid(ev))
return;
ev.stopPropagation();
ev.preventDefault();
});
element.addEventListener('dragleave', ev => {
if (ev.relatedTarget && element.contains(ev.relatedTarget))
return;
end_drop();
});
element.addEventListener('drop', ev => {
if (! is_valid(ev))
return;
ev.stopPropagation();
ev.preventDefault();
end_drop();
on_drop(ev);
});
}
export function sleep(t) {
return new Promise(res => {
setTimeout(res, t);
});
}
export function promise_event(element, success_event, failure_event) {
let resolve, reject;
let promise = new Promise((res, rej) => {
@ -36,21 +168,21 @@ export function promise_event(element, success_event, failure_event) {
reject = rej;
});
let success_handler = e => {
let success_handler = ev => {
element.removeEventListener(success_event, success_handler);
if (failure_event) {
element.removeEventListener(failure_event, failure_handler);
}
resolve(e);
resolve(ev);
};
let failure_handler = e => {
let failure_handler = ev => {
element.removeEventListener(success_event, success_handler);
if (failure_event) {
element.removeEventListener(failure_event, failure_handler);
}
reject(e);
reject(ev);
};
element.addEventListener(success_event, success_handler);
@ -61,18 +193,81 @@ export function promise_event(element, success_event, failure_event) {
return promise;
}
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)
throw new Error(`Failed to load ${url} -- ${xhr.status} ${xhr.statusText}`);
return xhr.response;
}
export function string_from_buffer_ascii(buf, start = 0, len) {
if (ArrayBuffer.isView(buf)) {
start += buf.byteOffset;
buf = buf.buffer;
}
return String.fromCharCode.apply(null, new Uint8Array(buf, start, len));
}
// Converts a string to a buffer, using NO ENCODING, assuming single-byte characters
export function bytestring_to_buffer(bytestring) {
return Uint8Array.from(bytestring, c => c.charCodeAt(0)).buffer;
}
export function b64encode(value) {
if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
value = string_from_buffer_ascii(value);
}
// Make URL-safe and strip trailing padding
return btoa(value).replace(/[+]/g, '-').replace(/[/]/g, '_').replace(/=+$/, '');
}
export function b64decode(data) {
return bytestring_to_buffer(atob(data.replace(/-/g, '+').replace(/_/g, '/')));
}
export function format_duration(seconds, places = 0) {
let sign = '';
if (seconds < 0) {
seconds = -seconds;
sign = '-';
}
let mins = Math.floor(seconds / 60);
let secs = seconds % 60;
let rounded_secs = secs.toFixed(places);
// TODO hours?
return `${sign}${mins}:${parseFloat(rounded_secs) < 10 ? '0' : ''}${rounded_secs}`;
}
export class DelayTimer {
constructor() {
this.active = false;
this._handle = null;
this._bound_alarm = this._alarm.bind(this);
}
set(duration) {
if (this._handle) {
window.clearTimeout(this._handle);
}
this.active = true;
this._handle = window.setTimeout(this._bound_alarm, duration);
}
_alarm() {
this._handle = null;
this.active = false;
}
}
// Cast a line through a grid and yield every cell it touches
export function* walk_grid(x0, y0, x1, y1) {
export function* walk_grid(x0, y0, x1, y1, min_a, min_b, max_a, max_b) {
// TODO if the ray starts outside the grid (extremely unlikely), we should
// find the point where it ENTERS the grid, otherwise the 'while'
// conditions below will stop immediately
@ -90,55 +285,56 @@ export function* walk_grid(x0, y0, x1, y1) {
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;
}
// Zero offset means we're on a grid line, so we're actually a full cell
// away from the next grid line
if (offset_x === 0) {
offset_x = 1;
}
let step_b = 1;
let offset_y = 1 - (y0 - b);
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;
}
if (offset_y === 0) {
offset_y = 1;
}
let err = dy * offset_x - dx * offset_y;
let min_a = 0, min_b = 0;
// TODO get these passed in fool
let max_a = 31, max_b = 31;
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;
@ -155,9 +351,13 @@ export function* walk_grid(x0, y0, x1, y1) {
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;
@ -171,3 +371,218 @@ export function* walk_grid(x0, y0, x1, y1) {
}
}
}
// 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
// - a local directory provided via the webkit Entry api
// - HTTP (but only for files we choose ourselves, not arbitrary ones, due to CORS)
// Note that where possible, these classes lowercase all filenames, in keeping with C2G's implicit
// requirement that filenames are case-insensitive :/
export class FileSource {
constructor() {}
// 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 {
constructor(files) {
super();
this.files = {};
for (let file of files) {
this.files[(file.webkitRelativePath ?? file.name).toLowerCase()] = file;
}
}
get(path) {
let file = this.files[path.toLowerCase()];
if (file) {
return file.arrayBuffer();
}
else {
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 {
// Should be given a URL object as a root
constructor(root) {
super();
this.root = root;
}
get(path) {
let url = new URL(path, this.root);
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
export class EntryFileSource extends FileSource {
constructor(entries) {
super();
this.files = {};
let file_count = 0;
let read_directory = async (directory_entry, dir_prefix) => {
let reader = directory_entry.createReader();
let all_entries = [];
while (true) {
let entries = await new Promise((res, rej) => reader.readEntries(res, rej));
all_entries.push.apply(all_entries, entries);
if (entries.length === 0)
break;
}
await handle_entries(all_entries, dir_prefix);
};
let handle_entries = (entries, dir_prefix) => {
file_count += entries.length;
if (file_count > 4096)
throw new LLError("Found way too many files; did you drag in the wrong directory?");
let dir_promises = [];
for (let entry of entries) {
if (entry.isDirectory) {
dir_promises.push(read_directory(entry, dir_prefix + entry.name + '/'));
}
else {
this.files[(dir_prefix + entry.name).toLowerCase()] = entry;
}
}
return Promise.all(dir_promises);
};
this._loaded_promise = handle_entries(entries, '');
}
async get(path) {
let entry = this.files[path.toLowerCase()];
if (! entry)
throw new LLError(`No such file in local directory: ${path}`);
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
// individual files as needed, but it's also pretty new so maybe later?
export class ZipFileSource extends FileSource {
constructor(buf) {
super();
// TODO async? has some setup time but won't freeze browser
let files = fflate.unzipSync(new Uint8Array(buf));
this.files = {};
for (let [path, file] of Object.entries(files)) {
this.files['/' + path.toLowerCase()] = file;
}
}
async get(path) {
let file = this.files[path.toLowerCase()];
if (! file)
throw new LLError(`No such file in zip: ${path}`);
return file.buffer;
}
iter_all_files() {
return Object.keys(this.files);
}
}

3
js/vendor/fflate.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
levels/CC2LP1.zip Normal file

Binary file not shown.

BIN
levels/CCLP5.ccl Normal file

Binary file not shown.

BIN
levels/lexys-lessons.zip Normal file

Binary file not shown.

BIN
levels/previews/cc2lp1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
levels/previews/cclp1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
levels/previews/cclp3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
levels/previews/cclp4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
levels/previews/cclp5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
levels/previews/cclxp2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
loading.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
music/asteroid-prairie.ogg Normal file

Binary file not shown.

BIN
music/canopy.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
music/inner-orbit.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

3
package.json Normal file
View File

@ -0,0 +1,3 @@
{
"type": "module"
}

BIN
sfx/drop.ogg Normal file

Binary file not shown.

BIN
sfx/exit.ogg Normal file

Binary file not shown.

BIN
sfx/fake-floor.ogg Normal file

Binary file not shown.

BIN
sfx/get-bonus.ogg Normal file

Binary file not shown.

BIN
sfx/get-bonus2.ogg Normal file

Binary file not shown.

BIN
sfx/get-chip-extra.ogg Normal file

Binary file not shown.

BIN
sfx/get-chip-last.ogg Normal file

Binary file not shown.

BIN
sfx/get-stopwatch-bonus.ogg Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
sfx/popwall.ogg Normal file

Binary file not shown.

BIN
sfx/push.ogg Normal file

Binary file not shown.

BIN
sfx/revive.ogg Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More