-
Notifications
You must be signed in to change notification settings - Fork 0
/
picohaven2.lua
3205 lines (2946 loc) · 128 KB
/
picohaven2.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--PICOhaven 2
--by icegoat, Aug-Oct '23
--Updates and web-playable version: https://www.lexaloffle.com/bbs/?tid=54850
--(this is a sequel to PICOhaven 1 at https://www.lexaloffle.com/bbs/?tid=45105)
--This file is run through a script to strip comments and minify
-- variable names and then included by the main picohaven2.p8 cart,
-- so these comments do not appear in the released cart
--see also picohaven2_source_doc.md as reference for
-- main state machine, sprite flags, and global variables
--This file and related files and dev notes will be available in a
-- github repo: https://github.com/icegoat9/picohaven2
---- Code organization:
----- 1) core game init/update/draw
----- 2) main game state machine
----- 3) pre-combat states (new turn, choose cards, etc)
----- 4) action/combat loop
----- 4a) enemy action loop
----- 4b) player action loop
----- 5) post-combat states (cleanup, etc)
----- 6) main UI draw loops and card draw functions
----- 7) custom sprite-based font and print functions
----- 8) menu-draw and related functions
----- 9) miscellaneous helper functions
----- x) pause menu items [deprecated]
----- 10) data string -> datastructure parsing + loading
----- 11) inits and databases
----- 12) profile / character sheet
----- 13) splash screen / intro
----- 14) levelup, upgrades
----- 15) town and retirement
----- 16) debugging + testing functions
----- 17) pathfinding (A*)
----- 18) load/save
--
-- PICOhaven game state machine
--
--
-- +-------------+ continue game +-----------+ +-------------+
-- | splash +----------------------------------> +----> retire* |
-- +-----+-------+ | | +-------------+
-- |new game +---------------------------+ town |
-- +-----v-------+ | | | +-------------+
-- | newlevel +------+ +----------------> +----> upgradedeck |
-- +-----+-------+ | +-^---^---^-+ +------+------+
-- | +-----v------+ | | | |
-- | | pretown | +-------v-+ | | +------v------+
-- +-----v-------+ +-----^------+ | profile | | +------+ upgrademod |
-- | newturn <--------+ | +---------+ | +-------------+
-- +-+-------^---+ | +-----+------+ |
-- | |review map | | endoflevel | +---v---+
-- +-v-------+---+ | +-----^------+ | store |
-- | choosecards | | | +-------+
-- +-----+-------+ | +-----+------+
-- | +--+ cleanup <--------------+
-- | +------------+ |
-- +-----v-------+ +-------+--------+
-- | precombat +-------------------------------> |
-- +-------------+ | actloop |
-- +-----------> <-----+
-- | +--+----------+--+ |
-- | | | |done
-- | +----------v---+ +--v--------+--+
-- | | actplayerpre | | actenemy |
-- | +---+----------+ +-------^------+
-- done| | |move
-- +--+-------v---+ +-------v------+
-- +----------> actplayer <----------+ |
-- | | | | animmovestep |
-- | +--> <--+ | |
-- | | +--+--------+--+ | +--^--------^--+
-- done| undo| | | |undo | |
-- +-+-------+-----v--+ +--v-----+------+ | |
-- | actplayerattack | | actplayermove +---+ |
-- +---------+--------+ +---------------+ |
-- push| |
-- +----------------------------------------+
-- custom font used as game icons cheatsheet:
-- shift + letter in pico8 font (replaced by custom font in game)
-- a█b▒c🐱d⬇️e░f✽g●h♥i☉j웃k⌂l⬅️m😐n♪o🅾️p◆q…r➡️s★t⧗u⬆️vˇw∧x❎y▤z▥
-- (shift+a = █, used to represent attack, shift+b=▒=burn, etc )
-- hiragana characters also used for some icons (w/ custom font)
-- aあiいuうeえoお kaかkiきkuくkeけkoこ saさsiしsuすseせsoそ
--some general config for shrinko-8 minifier (see also --lint prefixes to functions)
-- a few key variable names to not rename/minify, to simplify runtime debugging
--preserve: dlvl,state,actor,mvq,msgq,p,p_xp,p_gp,pdeck,tpdeck,edecks,pitems,mapmsg,wongame,lvls,dset2,dget2,msg_yd
-----
----- 1) core game init/update/draw functions, including animation queue
-----
function _init()
--debugmode=false
--logmsgq=true
--if (logmsgq) printh('\n*** new cart run ***','msgq.txt')
-- godmode=true --power up character for debugging and rapid play-through testng
dlvl=2 --starting dungeon #
-- stop("level debug mode.\ntype 'dlvl=##' to set level, then 'resume'")
initfont()
initglobals()
initdbs()
initpersist()
--interpret pink (color 14) as transparent for sprites, black is not transparent
palt(0b0000000000000010)
music(16)
changestate("splash")
end
function _draw()
--if wipe>0, a "screenwipe" is in progress, only update part of screen
local s=128-2*wipe
clip(wipe,wipe,s,s)
_drwstate() --different function depending on state, set by state's init fn
clip()
--debugging routine for displaying performance / cpu usage across various parts of code
--if using, need to umcomment statstr= and addstat1() related code elsewhere
-- print("\#a"..stat(7).."fps "..predrawstat1..","..(stat(1)*100\1)..","..stat(2).."%cpu",0,0,1)
-- addstat1()
-- print(statstr,0,0,1)
end
--lint:shake,msgreview,_updprev
function _update60()
-- statstr="\#a" --debug stat(1) usage string
if animt<1 then
--if a move/attack square-to-square animation is in progress,
-- only update that frame by frame until complete
_updonlyanim()
else
--normal update routine
shake=0 --turn off screenshake if it was on (e.g. due to "*2" mod card drawn)
--move killed enemies off-screen (but only once any attack animations
-- being processed via _updonlyanim() have completed)
-- (they will then be deleted from actor[] at end of turn)
for a in all(actor) do
if (a.hp<=0 and a!=p) a.x=-99
end
_updstate() --different function depending on state, set by state's init fn
end
_updtimers()
end
--regardless-of-state animation updates:
-- update global timer tick, animation frame, screenwipe, message scrolling
--lint: msgpause
function _updtimers()
--common frame animation timer ticks
fram+=1
afram=flr(fram/act_td)%4
--if screenwipe in progress, continue it
wipe=max(0,wipe-5)
--every msg_td # of frames, scroll msgbox 1px
--msgq auto-scrolls (even in review mode), until player
-- presses up arrow (see _updscrollmsg() which sets msgpause)
if fram % msg_td==0 and #msgq>3 and msg_yd<(#msgq-3)*6 and not msgpause then
msg_yd+=1
end
end
--run actor move/attack animations w/o user input until done
-- kicked off by setting common animation timer animt to 0
-- this function then gradually increases it 0->1 (=done)
function _updonlyanim()
animt=min(animt+animtd,1)
for a in all(actor) do
--pixel offsets to draw each sprite at relative to its 8*x,8*y starting location
a.ox,a.oy=a.sox*(1-animt),a.soy*(1-animt)
if animt==1 then
a.sox,a.soy=0,0
--delete ephemeral 'actors' that are not really player/enemy actors (e.g. "damage number" sprites)--
-- they were only created and added to actor[] to reuse this code to animate them
if (a.ephem) del(actor,a)
end
end
end
-----
----- 2) main game state machine
-----
--the core of the state machine is to call changestate() rather
-- then directly edit the 'state' variable. this function calls
-- a relevant init() function (which updates update and draw functions)
-- and resets some key globals to standard values to avoid need
-- to reset them in every state's init function
--lint: selx,sely,seln,msg_x0,msg_w,selvalid,showmapsel
function changestate(_state,_wipe)
prevstate=state
state=_state
selvalid,showmapsel=false,false
selx,sely,seln=1,1,1
setprompt()
--screen wipe on every state change, unless passed _wipe==0
wipe = _wipe or 63
--reset msgbox x + width to defaults
msg_x0,msg_w=0,map_w
--run specific init function defined in initglobals()
if (initfn[_state]) initfn[state]()
end
-- a simple wait-for-🅾️-to-continue loop used as update in various states
function _upd🅾️()
---if (showmapsel) selxy_update_clamped(10,10,0,0)
if (btnp(🅾️)) changestate(nextstate)
end
-----
----- 3) the "pre-combat" states
-----
---- state: new level
--lint: mapmsg
function initnewlevel()
initlevel()
music(0) --theme music
mapmsg=pretxt[dlvl]
setprompt("\fc🅾️\f6:bEGIN")
nextstate,_updstate,_drwstate="newturn",_upd🅾️,_drawlvltxt
end
--display the pre- or post-level story text in the map frame
--TODO? merge into drawmain (since similar) + use a global to set whether map or text is displayed
-- but: that would become less clear, might only save ~15tok
function _drawlvltxt()
clsrect(0)
drawstatus()
drawmapframe()
printwrap(mapmsg,21,4,10,6)
drawheadsup()
drawmsgbox()
end
---- state: new turn
function initnewturn()
--purge all but the last N elements of previous turns' msgq (hardcoded to save tokens)
-- to avoid slowdowns seen especially if #msgq > 100 items
--TODO? could in future save tokens by removing this and returning to resetting
-- the queue every turn rather than only in initlevel()
while #msgq>30 do
deli(msgq,1)
msg_yd=max(0,msg_yd-6) --move msg pointer up a line of pixels at the same time
end
addmsg("\f7----- nEW rOUND ------")
setprompt("\fcさし\f6:iNSPECT mAP \-f🅾️\f6:cARDS")
selx,sely,showmapsel=p.x,p.y,true
_updstate,_drwstate=_updnewturn,_drawmain
end
function _updnewturn()
selxy_update_clamped(10,10,0,0) --11x11 map
if (btnp(🅾️)) changestate("choosecards")
end
--shared function used in many states to let player use
-- arrows to move selection box in x or y, clamped to an allowable range
function selxy_update_clamped(xmax,ymax,xmin,ymin)
--sets default xmin,ymin values of 1 if not passed to save a
-- few tokens by omitting them in function calls (this is why they are
-- listed last as function parameters, so they'll default to nil if omitted)
--this approach is used widely in code to set default parameters
--TODO? if we could allow a default xmin and ymin of 0, we could save a few tokens
-- by eliminating the following line since mid() assumes nil parameters are 0
-- but Lua is 1-indexed so min values of 1 are simpler elsewhere
xmin,ymin = xmin or 1, ymin or 1
--loop checking which button is pressed
for i=1,4 do
if btnp(i-1) then
selx+=dirx[i]
sely+=diry[i]
break --only allow one button to be enabled at once, no "diagonal" moves
end
end
--clamp to allowable range
selx,sely=mid(xmin,selx,xmax),mid(ymin,sely,ymax)
--item #n in an x,y grid of items
--TODO?: also clamp seln to a max value? (not currently needed)
seln=(selx-1)*ymax+sely
end
---- state: choose cards
--lint: tpdeck
function initchoosecards()
--create a semi-local copy of pdeck (that adds the "rest"
-- and "confirm" virtual cards that aren't in deck and shouldn't
-- show up in character profile view of decklist)
tpdeck={}
for crd in all(pdeck) do
add(tpdeck,crd)
end
--add "long rest" card (see init fns)
refresh(longrestcrd)
add(tpdeck,longrestcrd)
--add "confirm" option, implemented as a card
add(tpdeck,splt("act;confirm;status;1;name;\nconfirm\n\n\f6confirm\nthe two\nselected\ncards",false,true))
setprompt("\fc🅾️\f6:sELECT 2 cARDS \fc❎\f6:mAP")
p.crds={}
_updstate,_drwstate=_updhand,_drawhand
end
--"selecting cards from hand" update function
function _updhand()
selxy_update_clamped(2,(#tpdeck+1)\2)
--if tpdeck has an odd number of cards, don't let selector move
-- to the unused (bottom of column 2) location
--TODO? build this into selxy_update_clamped() instead
-- (but only used in this one location, not worth the abstraction?)
if (seln>#tpdeck) sely-=1
if tutorialmode then
local promptstr=splt("\fcさし🅾️\f6:sELECT 1ST CARD;\fcさし🅾️\f6:sELECT 2ND CARD;\fcさし🅾️\f6:\f7confirm\f6 IF DONE")
setprompt(promptstr[#p.crds+1])
end
if btnp(🅾️) then
local selcrd=tpdeck[seln]
--status (0=in hand, 1=discarded, 2=burned)
if selcrd.status==0 then
--card not discarded/burned, can select
if indextable(p.crds,selcrd) then
--card was already selected: deselect
del(p.crds,selcrd)
else
--select card
if selcrd.act=="rest" then
--clear other selections
p.crds={}
end
if seln==#tpdeck then
--if last entry "confirm" selected, move ahead with card selection
-- NOTE: that "confirm" can only be selected if it is enabled
-- (card.status==0), which is only set if 2 cards are selected
--set these cards to 'discarded' now even before we get to playing them
-- (so a "burn random undiscarded card to avoid damage"
-- trigger before player turn can't use them)
for c in all(p.crds) do
c.status=1
end
pdeckbld(p.crds)
changestate("precombat")
elseif #p.crds<2 then
--if a new card is selected (and <2 already selected)
add(p.crds,selcrd)
end
end
--enable "confirm" button if and only if 2 cards selected,
-- otherwise set it to "discarded" mode to grey it out
tpdeck[#tpdeck].status = #p.crds==2 and 0 or 1
end
elseif btnp(❎) then
-- review map... by jumping back to newturn state
changestate("newturn")
end
end
function _drawhand()
clsrect(5)
print("\f6yOUR dECK:\n\n\n\n\n\n\n\n\n\n\n\n\n\*f \*7 \+celEGEND:",8,24)
drawcard("discard",92,108,1)
drawcard("burned",92,118,2)
--split deck into two columns to display
local tp1,tp2=splitarr(tpdeck)
drawcardsellists({tp1,tp2},0,27,p.crds,9)
--tip on initiative setting
if (#p.crds<1) printmspr("\f61ST cARD CHOSEN\nSETS \f7iNITIATIVE,\f6\nlOW:aCT fIRSTす",61,3)
--drawmsgbox() --commented out to eliminate msgbox in drawhand mode, just use single-line prompt
drawprompt()
end
--create list of options-for-turn to display on player
-- box in HUD, from selected cards
--lint: restburnmsg
function pdeckbld(clist)
--if first card is "rest", only play that and burn other
if clist[1].act=="rest" then
--message will be displayed later in turn, when you play rest
restburnmsg="\f8burned\f6 ["..clist[2].act.."]"
clist[2].status=2
deli(clist,2)
else
--add default alternate actions 😐2/█2 to options for turn
-- (unless certain items held that modify these)
local defmove=hasitem("swift") and "😐3" or "😐2"
--TODO: comment out below testing-only hack to give powerful default move
--local defmove=hasitem("swift") and "😐8" or "😐5웃"
if (hasitem("belt")) defmove..="웃"
add(clist,{act=defmove})
add(clist,{act=hasitem("quivr") and "█2➡️3" or "█2"})
--TODO: comment out below testing-only hack w/ powerful defaults
--add(clist,{act=hasitem("quivr") and "█2➡️3" or "█0◆3"})
end
end
---- state: precombat
--lint: initi
function initprecombat()
--draw enemy cards for turn
selectenemyactions()
--ilist[]: global sorted-by-initiative list of actors
--initi: "who in ilist[] is acting next"?
ilist,initi=initiativelist(),1
--TODO: decide if these tutorial tips are worth all the tokens / redundancy
local msg="🅾️:bEGIN tURNS"
if tutorialmode then
msg="🅾️:bEGIN ("..ilist[1].name.." FIRST @iNIT \f7"..ilist[1].init.."\f6)"
end
setprompt(msg)
nextstate,_updstate,_drwstate="actloop",_upd🅾️,_drawmain
end
--draw random action card for each enemy type and set
-- relevant global variables to use this turn
function selectenemyactions()
local etypes=activeenemytypes()
for et in all(etypes) do
et.crds = rnd(edecks[et.id])
et.init = et.crds[1].init
end
for a in all(actor) do
--add link to crds for each individual enemy
--TODO: rethink this and remove redundancy of both enemy type and enemy
-- having .init and .crds, but complicated by player not
-- having a .type (enemy type)
if (a.type) a.crds=a.type.crds
a.init=a.crds[1].init
a.crdi=1 --index of card to play next for this enemy
end
end
--generate list of active enemy types (link to enemytype[] entries)
-- from list of actors (only want one entry per type even if many instances of an enemy type)
function activeenemytypes()
local etypes={}
for a in all(actor) do
if (a!=p and not(indextable(etypes,a.type))) add(etypes,a.type)
end
return etypes
end
-----
----- 4) the "actor action" / combat states
-----
---- NOTE: see picohaven2_source_doc.md for a diagram of the
---- state machine: many interconnected actionloop states
---- general action loop state (which will step through each actor, enemy and player)
function initactloop()
_updstate=_updactloop
_drwstate=_drawmain --could comment out to save 3 tokens since hasn't changed since last state (but that's risky/brittle to future state flow changes)
end
--each time _updactloop() is called, it runs once and
-- dispatches to a specific player or enemy's action function (based on value of initi)
--lint: actorn
function _updactloop()
if p.hp<=0 then
loselevel()
return
end
if initi>#ilist then
--all actors have acted
changestate("cleanup",0)
return
end
actorn=ilist[initi].id --current actor from ordered-by-initiative list
local a=actor[actorn]
--increment index to actor, for the _next_ time this function runs
initi+=1
--if actor dead, silently skip its turn
if (a.hp<1) return
--below tutorial note commented out to save ~20tokens
--if (tutorialmode) addmsg("@ initiative "..ilist[initi-1][1]..": "..a.name)
if a==p and p.crds[1]==longrestcrd then
--special case: long rests always run (even if stunned), w/o player interaction needed
longrest()
--in case player stunned. TODO? move this line into longrest()
p.stun=nil
elseif a.stun then
--skip turn if stunned
addmsg(a.name.." ▥, TURN SKIPPED")
a.stun=nil
else
if (a==p) then
changestate("actplayerpre",0)
else
changestate("actenemy",0)
end
end
end
-----
----- 4a) the enemy action loop states
-----
---- state: actenemy
function initactenemy()
_updstate=_updactenemy
_drwstate=_drawmain --could comment out to save 3 tokens since hasn't changed since last state (but that's risky/brittle to future state flow changes)
end
--execute one enemy action from enemy card
--(will increment global actor.crdi and run this multiple times if enemy has multiple actions)
function _updactenemy()
local e=actor[actorn]
--if all cards played, done, advance to next actor
if e.crdi>#e.crds then
changestate("actloop",0)
return
end
--generate current "card to play"'s data structure, set global e.crd
e.crd=parsecard(e.crds[e.crdi].act)
--advance index for next time
e.crdi+=1
if e.crd.act=="😐" then
--if current action is a move, and enemy will have a ranged attack as the following action,
-- store that attack range so move can stop once it's within range
--NOTE: crdi below now refers to the 'next' card because of the +=1 above
if e.crdi<=#e.crds then
local nextcrd=parsecard(e.crds[e.crdi].act)
if (nextcrd.act=="█") e.crd.rng=nextcrd.rng
end
end
runcard(e) --execute specific enemy action
end
-- actor "a" summons its summon (and loses 2 hp)
-- (written for only enemy summoning, but could be extended for player summons in future chapter of game)
function summon(a)
local smn=a.type.summon
local neighb=valid_emove_neighbors(a,true) --valid adjacent squares to summon into
if #neighb>0 then
local smnxy=rnd(neighb)
initenemy(smn,smnxy.x,smnxy.y)
addmsg(a.name.." \f7SUMMONS\f6 "..enemytype[smn].name..",\f8-2♥\f6")
--hard-coded that summoning always inflicts 2dmg to self
dmgactor(a,2)
end
end
-- moderately complex process of pathfinding for enemy moves (many sub-functions called)
function enemymoveastar(e)
--basic enemy A* move, trimmed to allowable move distance:
mvq = trimmv(pathfind(e,p),e.crd.val,e.crd.rng)
--if no motion would happen via the normal "can pass thorugh allies" routing,
-- enemy could be stuck behind one of its allies-- in this case, try routing
-- with "allies block motion" which may produce a useful "route around" behavior
if not mvq or #mvq<=1 then
mvq = trimmv(pathfind(e,p,false,true),e.crd.val,e.crd.rng)
end
--animate move until done (then will return to actenemy for next enemy action)
changestate("animmovestep",0)
end
--trim down an ideal unlimited-steps enemy move to a goal,
-- by stopping once either enemy is within
-- range (with LOS) or enemy has used up move rating for turn
function trimmv(_mvq,mvval,rng)
if (not _mvq) return _mvq
local trimto
for i,xy in ipairs(_mvq) do
local v=validmove(xy.x,xy.y,true)
if i==1 or v and i<=(mvval+1) then --equivalent to 'i==1 or (v and ...)'
trimto=i
--if xy is within range (1 unless ranged attack) and has LOS, trim here, skip rest of for loops
if (dst(xy,p)<=rng and lineofsight(xy,p)) break
end
end
return {unpack(_mvq,1,trimto)}
end
-- --WIP more complex pathfinding algorithm (on hold for lack of code
-- -- space / tokens, and current draft is buggy)
-- --Plan A* moves to all four cells adjacent to player, determine which of these
-- -- moves is 'best' (if none are adjacent or in range of a ranged attack, which
-- -- partial move ends with the shortest path to the player in a future turn?)
--function enemymoveastaradvanced(e)
-- --bug: this routing allows enemy to move _through_ player to an open spot on other side
-- --minor bug: enemies don't always route around
-- --This is ~50 tokens more than a simpler single A* call
-- local potential_goals=valid_emove_neighbors(p,true)
-- bestdst,mvq=99,{}
-- for goal in all(potential_goals) do
-- local m=find_path(e,goal,dst,valid_emove_neighbors)
-- m=trimmv(m,e.crd.val,e.crd.rng)
-- if m then --if non-nil path returned
-- --how many steps would it take from this path's
-- -- endpoint to reach player in future?
-- local d=#find_path(m[#m],p,dst,valid_emove_neighbors)
-- if d<bestdst then
-- bestdst,mvq=d,m
-- end
-- end
-- end
-- changestate("animmovestep",0)
--end
-- general "valid move?" function for all actors
function validmove(x,y,endat,jmp,actornum,allyblocks)
--endat: if true, validate ending at this
-- spot (otherwise checking pass-through)
--jmp: jumping (can pass over some obstacles and enemies if not ending at this location)
--allyblocks: do enemies' allies block their movement?
-- (by default enemies can pass through though not end moves on allies)
--actornum: actor[] index of moving actor (1: player)
--unjumpable obstacles (walls, fog)
if (fget(mget(x,y),1) or isfogoroffboard(x,y)) return false
--obstacle w/o jump (or even w/ jump can't end at)
if (fget(mget(x,y),2) and (endat or not jmp)) return false
--can't walk through actors, except enemies through their allies
-- or jumping past them
local ai=actorat(x,y)
--can't end on actor (except, actors can end of self i.e. 0 move)
if (endat and ai>0 and actornum!=ai) return false
--by default, enemies can pass through allies
-- (unless we pass the 'ally blocks moves' flag,
-- used to break out of some routing deadlocks)
if ((allyblocks or actornum==1) and ai>1 and not jmp) return false
return true
end
-- return list of "valid-move adjacent neighbors to (node.x,node.y)"
-- used in A* pathfind(), for example
function valid_emove_neighbors(node,endat,jmp,allyblocks)
--see parameter descriptions in validmove()
local neighbors = {}
for i=1,4 do
local tx,ty=node.x+dirx[i], node.y+diry[i]
if validmove(tx,ty,endat,jmp,nil,allyblocks) then
add(neighbors, xylst(tx,ty))
end
end
return neighbors
end
----wrapper to above allowing jmp, to pass to A* pathfind
----Note: moved to inline anonymous function since only used once in program
--function valid_emove_neighbors_jmp(node)
-- return valid_emove_neighbors(node,false,true)
--end
----wrapper allowing enemies to move through allies, for A* calls
----Note: moved to inline anonymous function since only used once in program
--function valid_emove_neighbors_allyblocks(node)
-- return valid_emove_neighbors(node,false,false,true)
--end
--execute attack described in attacker's card a.crd, against defender d
--lint:apushed,aoe_xy_center,pushdmg,apushedinto,pushblockx,pushblocky
function runattack(a,d)
--a = attacker, d = defender (in actor[] list)
local crd=a.crd
--save values before modifier card drawn
local basewound=crd.wound
--local basestun=crd.stun --TODO: check that no mod cards set stun...
local dmg=crd.val
local msg=""
--draw attack mod card (currently player-only)
if a==p then
local mod=drawmodcard()
-- TODO? restore or remove these old more verbose PICOhaven 1 mod card messages
--if (tutorialmode) addmsg("yOU DRAW mODIFIER \f7"..mod)
if (tutorialmode) addmsg("yOU DRAW RANDOM ATTACK\n mODIFIER CARD \f7"..mod)
msg="\f7["..mod.."\f7]\f6 \-f"
if mod=="*2" then
dmg*=2
shufflemoddeck()
shake=3 --screenshake of 3 pixels to emphasize drawing of *2 card
elseif mod=="/2" then
dmg\=2
shufflemoddeck()
else
--check for mod card conditions
if mod[-1]=="∧" then
crd.wound=true
mod=sub(mod,1,#mod-1)
----TODO: remove this stun-handling code if no mod cards add stun
--elseif mod[-1]=="▥" then
-- crd.stun=true
-- mod=sub(mod,1,#mod-1)
end
--modify damage via mod
--avoid negative damage. some duplicate tokens with
-- max(0,dmg) below, but this is needed so that the
-- msg..= cmd in shld area below reads right in
-- if there's "negative damage" pre-shield
dmg=max(0,dmg+tonum(mod))
end
end
-- below runs for all actors
sfx(1)
-- do damage and effects
msg..=a.name.."█"..d.name
if d==p and hasitem("shld",true) then
p.shld+=2
addmsg("\f7gREAT sHIELD USED\f6:+★2")
end
if d.shld>0 then
msg..="("..d.shld.."★)"
dmg=dmg-d.shld
end
dmg=max(0,dmg)
msg..=":\f8-"..dmg.."♥\f6"
if a.crd.stun then
msg..="▥"
d.stun=true
end
if a.crd.wound then
msg..="∧"
d.wound=true
end
--push attack (but don't push if it was a killing blow)
if a.crd.push and d.hp>dmg then
--build push move queue, starting with defender current loc
--msg..="◆"
mvq={xylst(d.x,d.y)}
for i=1,a.crd.push do
--"d.x-a.x" is push_deltax: direction of push
local tx,ty=d.x+i*(d.x-a.x),d.y+i*(d.y-a.y)
-- stop("can we push to "..tx..","..ty.."?") --debug message
if validmove(tx,ty,true) then
add(mvq,xylst(tx,ty))
-- stop("yes, added to mvq as#"..#mvq) --debug message
else
-- stop("no, obstacle") --debug message
--queue up damage at end of move animation in initanimmovestep()
pushdmg=i
--actor on the receiving end of a push collision, if any
-- (will be 0 if not)
apushedinto=actor[actorat(tx,ty)]
pushblockx,pushblocky=tx,ty --globals for an animation
-- end
break --don't move past obstacle
end
end
--execute push itself below (after damaging actor)
end
--reset card .stun and .wound-- only relevant if a
-- multi-target attack AND .stun/.wound were applied
-- by a modifier card (so should not necessarily be
-- applied ot all targets)
crd.wound=basewound
--crd.stun=basestun --commented out since no mod cards set stun...
addmsg(msg)
--prepare attack animation
local aspr=144+dmg
if (dmg>9) aspr=154
queueanim(nil,d.x,d.y,a.x,a.y,aspr)
dmgactor(d,dmg)
--if we'll push at least one square w/ a push action
--checking "a.crd.push" may be unneeded, it's to avoid false triggers
-- just in case mvq still exists from another routine and wasn't
-- cleared (commenting out for now, but has some bug risk...)
-- if a.crd.push and #mvq>1 then
if #mvq>=1 then
apushed=d --global, tells animmovestep non-active actor is moving
changestate("animmovestep",0)
end
end
--draw player attack modifier card
-- (and maintain a discard pile and dwindling deck)
function drawmodcard()
if #pmoddeck==0 then
shufflemoddeck()
end
local c = rnd(pmoddeck)
add(pmoddiscard,c)
del(pmoddeck,c)
return c
end
--try to have enemy attack
function enemyattack(e)
if dst(e,p) <= e.crd.rng and lineofsight(e,p) then
runattack(e,p)
end
end
function healactor(a,val)
local heal=min(val,a.maxhp-a.hp)
a.hp+=heal
if (heal>0 or a.wound) addmsg(a.name.." hEALED \f8+"..heal.."♥")
a.wound=nil
end
--damage actor and check death, etc
function dmgactor(a,val)
a.hp-=val
if a.hp<=0 then
if a==p then
--TODO? (tbd draft): shift to new more frantic music for rest of level.
-- ideally check if we've already done this to avoid music restart...
-- if used, would need to reset neardeath=false in initlevel()
-- could also only trigger this on burned card subset of conditions to avoid trigger before actual death
-- alternately, this could just call a special SFX (only 3 tokens) rather than new music
--if not neardeath then
-- neardeath=true
-- music(foo)
--end
if hasitem("life",true) then
a.hp,a.wound=1,false
addmsg("\f7yOUR lIFE cHARM GLOWS\n AND YOU SURVIVE @ 1hp")
else
-- burn random card in hand to negate dmg
local crd=rnd(cardsleft())
if crd then
crd.status=2
a.hp+=val
addmsg("yOU \f8bURN\f6 A RANDOM CARD\n\f8[\f6"..crd.act.."\f8]\f6 \-fTO AVOID DEATH")
end
end
--player near-death sound effect
sfx(3)
else
addmsg(""..a.name.." IS dEFEATED!")
--sfx(2)
if a.name=='orb' then
--as each orb is broken, reduce boss shield
local boss=actor[indextable(actor,"noah","name")]
--TODO? (tbd): could save a few tokens by removing this "if boss"
-- check, which guards against a very unlikely no-boss-exists crash bug
-- where player kills boss earlier in round, then also kills an
-- orb later that round before victory triggers
if boss then
boss.pshld\=2
boss.shld\=2 --otherwise won't happen until end of turn cleanup's shld=pshld
addmsg("\fcnOAH HOWLS AS THE AURA\n\fc AROUND HIM WEAKENS..")
end
end
--clear move queue: should only be relevant if actor was in
-- the middle of moving or being pushed when it hit a trap
-- and died: we want to abort the moving animation
mvq={}
--drop coin (except for "object" enemies like gravestones and orbs),
-- note: coin won't be visible until check in _update60() removes
-- enemy sprite from play area
if not a.obj then
--TODO? comment out this tutorial line to save tokens, unnecessary
--if (tutorialmode) addmsg(" AND DROPS gOLD (●)")
local m=mget(a.x,a.y)
local cspr=13 --coin sprite
-- if there's already 1 or 2 coins on this space, update to
-- the sprite that indicates a 2-3 coin stack (dropping >3 coins
-- on the same location should be rare, in those cases it maxes
-- out as a 3-coin stack)
if m>=13 and m<=15 then
cspr=min(15,m+1)
elseif m!=33 then
--if it's not a blank space or coin already, abort and don't
-- drop gold (don't overwrite an herb or other campaign object)
cspr=m
end
mset(a.x,a.y,cspr)
end
p_xp+=1
camp_kills+=1 --campaign stat
end
end
end
-----
----- 4b) player actions
-----
--first time entering actplayer for turn
function initactplayerpre()
p.actionsleft=2
changestate("actplayer",0)
end
--each time entering actplayer (typically runs twice/turn,
-- for 1st + 2nd actions fof turn, but also runs after 'undo', etc)
function initactplayer()
--checks for ended-on-trap-with-jump-on-prev-move,
-- since that wouldn't be caught during animmovestep
checktriggers(p)
if (p.actionsleft == 0) then
p.crds,p.init=nil --assignment with misssing values sets p.init to default of nil
changestate("actloop",0)
return
end
--setprompt("\fcし🅾️\f6:cHOOSE cARD "..3-p.actionsleft)
--setprompt("\fcし🅾️\f6:choose card or dflt")
setprompt(tutorialmode and "\fcし🅾️\f6:card (OR dFLT █2😐2)" or "\fcし🅾️\f6:cHOOSE cARD "..3-p.actionsleft)
_updstate=_updactplayer
_drwstate=_drawmain --could comment out to save 3 tokens since hasn't changed since last state (but that's risky/brittle to future state flow changes)
end
--loops in this routine until card selected and 🅾️, then runs that card
-- (and then the called function typically changes state to actplayer
-- when done, to rerun initactplayer above before running this again)
--lint: crdplayed,crdplayedpos
function _updactplayer()
selxy_update_clamped(1,#p.crds) --let them select one of p.crds
if btnp(🅾️) then
--crd = the card table (has .init, .act, .status, .name)
--note: if card in players deck, this is a reference to an entry in pdeck
-- so edits to crd (e.g. changing crd.status to discard or burn it) also edit the original in pdeck for future turns
local crd=p.crds[sely]
--global copy to restore if needed for an undo
crdplayed,crdplayedpos=crd,indextable(p.crds,crd)
--parse just the action string into data structure
p.crd=parsecard(crd.act)
--special-case modification of range, dmg based on items
if p.crd.rng and p.crd.rng>1 then
if (hasitem("goggl")) p.crd.rng+=3
--we know val is dmg if it's a ranged card
if (hasitem("razor")) p.crd.val+=1
end
p.actionsleft -= 1
runcard(p)
--note: card was already set to 'discarded' (crd.status=1) back
-- when cards were chosen from hand
if (p.crd.burn) crd.status=2 --burn card instead
del(p.crds,crd) --delete from list of cards shown in UI
end
end
--execute the card that has been parsed into a.crd
-- (where a is a reference to an entry in actor[])
-- (called by _updactplayer() or _updactenemy()
function runcard(a)
local crd=a.crd
if crd.act=="😐" then --a move action
if a==p then
changestate("actplayermove",0)
else
enemymoveastar(a)
end
elseif crd.aoe==8 and crd.rng==1 then
--specific player AoE attack 'all adjacent' where no UI
-- interaction to select targets is needed
--TODO? generalize for multiple different AoE attacks (aoepat[#]?)
-- but AoE attacks w/ selectable targets/directions would need to
-- happen in an interactive attack mode like "actplayerattack"
--TODO: save tokens here by reusing actplayerattack state in some way,
-- similar code in both
--list of the 8 (x,y) offsets relative to player hit by this
-- 'all surrounding enemeies' AoE, hard-coded as string for minimal tokens
local targets=splt3d("x;-1;y;-1|x;0;y;-1|x;1;y;-1|x;-1;y;0|x;1;y;0|x;-1;y;1|x;0;y;1|x;1;y;1",true)
aoe_xy_center=p --global used by addxydelta(), workaround for foreach() not taking multiple arguments
foreach(targets,addxydelta) --modifies aoepat in place
foreach(targets,pattackxy) --run attack for each AoE square
changestate("actplayer",0)
elseif crd.act=="█" then --standard attack
if a==p then
changestate("actplayerattack",0) --UI for target selection
else
enemyattack(a) --run enemy attack for actor a
end
else --other simpler actions without UI/selection
--Note: currently each action is assumed to only do one thing,
-- e.g. move, attack, heal, or so on.
--TODO? implement code to allow player heal/shield actions
-- attached to a move/attack? (not needed for now)
if (crd.act=="♥") healactor(a,crd.val)
if crd.act=="★" then
a.shld+=crd.val
addmsg(a.name.." ★+"..crd.val)
elseif crd.act=="⬅️" and a==p then
addmsg("lOOTING tREASURE @➡️"..crd.val)
rangeloot(crd.val)
elseif crd.act=="smite" then --god-mode attack (for testing, not actual game)
foreach(inrngxy(p,crd.rng),pattackxy)
elseif crd.act=="howl" then --special enemy attack
addmsg(a.name.." hOWLS.. \f8-1♥,▥")
dmgactor(p,1)
p.stun=true
elseif crd.act=="call" then
summon(a)
end
if (a==p) changestate("actplayer",0)
end
if (crd.burn) p_xp+=2 --using burned cards adds xp
end
--run with foreach() to transform an aoe list of {x,y} deltas
-- relative to a target into absolute positions
--requires global xydelta.x and .y are set before calling-- ugly!
-- (but since used with foreach() we can only have one argument)
--TODO:prototype alternate methods