0,8H L (U(0b08i8HyHYDmY}iu։""""""""""""""""""""""""""""""""DFDDDFDDffffdDDFdDDFffffDdDDDdDDDDDDDDDDDDDDDDffhhhffffffffllflfffffffnnfffnfffnfffffnflfflfflfflff̪jffffj""""//////"/"""/""""""""r"'r'"""""'""'"'""""""r"'r"r""""///"//"/"""/"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""2#""""2"""##""2"2"""cD2CS3"#bD$"#"2#"""""""""3#""""44""#"""D4BD"#3"2""""""#23"""""#TS%'D#E7DDD6"""#"3#2""""22"""""""E3"B335B$R2!"""!""$!"!D"Q!D$D!DDD!TED""""2BECEdDT%C6TDDDTDDDUDDTU""""DDDDDDDDfD4cEDDDUUTDUUDD!""""$$$"D"""4"""T"$"TBD"DDD"!#!"!!!""!"""!!23!32!!3#"!2!!!23!2""#!"!2"3"3"3!"#!##1#"!1113"U##"%"2!313#""33#RS1#S#33"33##22"13!2S2!12#!"2#113!U##!%"2!113!""31#RS1#S#33"33##22"13!2S2!12#!"2#"R##2SS$%SRB"cST"Cr'#B#""c22"cC#"""g""DF$#$f#$DG"Rbv"rD4$rT52tDD&"""UDETDUUDFTDEgwff"D"(DD5(TDEGr"RSs4R#b4""dD#RfW#S"#2"#$2"2""3!UUE!BD3!"DD!D$U!D"U!T$U!DEU!DEUD3TED3DTDSSEESTBEDEDUDUDUDUTE3TUUEDDDDDDDDDUDDDUDDDDDDDTUDDUUDTUDTD"EUE"5D$"5D""EB$"E"$"%B$"%T$"!#""3#233"332#!!!"!"""1#!"23!""""""3"!!!!""2!"!!!!2#33"3333#33"#efVfe36S311333"2"!23"!23212S5VffVU36S31#133#Q#3#13"3133#13"#afVfQ36S!311331"2"!23"!23212S5VffVU36S31133Q##2bsw"3S$"b43"bDR"Bsw"CR4"bCR"bCBGwgvUDD$5UD%4DD$www$4C4T"542wwvGESUD3R5T42CDwwwhUD"G4$2GDC3HtW22U#2#D3RRE"""wV2334"#%B"#CC#3!""E"""E!TEE!DDD!DDD!DDD!DDD!DD4D3TUD3DDD34DDRTUD"UUDDUUDDUUDTUUEDUUEDUUDDUTUDTUUETEUDTUUDTUUTDTUT$"%#""%"""%T%"$D$"$D$"%DD"%DD"!3"23233!3""!""!"!2!3"2!""""!"!!"!!3#"3#33"#""!"23""3#2""!!#""""13R#3R2313338cUSS##32##23!2"8!313Á3U1(!"##!2#318R#23333c!8SS!#32!#23!22!3"133338cUQ!"##!2#3Rb#b2U"2R#"BB333"""2"""""#"#23wwUEDD5ST$TDDD#$"3"2##"R&#"BD"3UUDSR5DBBCD#3#""B23"3bf#"3"wx"3TF"#DF"SD6"c3""2D"""4$"""2""!D$3!BB4!TED!DEB!DTD!DDB"!""STUU"TUUDUUUDDD"DDTBDDDR"""EDEDED$BD4CUUD$RDE$TD$DD""""$DD"%B$"5$$"TT$"EDD"DDD"!"""!"""!""!!"3!"3"!3!"""""""""#!""2#2#""213!"""3"!#"!"""""!"##S##"""#31##Q"2333!323#"#!2#2!"3"!3#!"25323%"33533!#S#!"""!31#!Q"21331!"123#!#"!2#2!"3"!3#!"2513231%"3#353vvwvwHvxf%BG3#&3#&SSF&c3wwffHhTBf"rC22S"2S"SXtffwwhHf$EH4'"H5##HS#"GX5"gwggwgx$Rfx23th23bX55bh36bd""#"%""2"""##""2"2""'cD2C#2"#bD$"#"2#"""""""""3#""""44""#"""D4BD""""2""""""#23"""""#TS%'D#E7DDD6"""""3#2"""22"R""""""E3"B335B$R26336##632xVXVTE&""'3""WUt""˜"2HDx˼y5Rw'"RW'Sbu)GUu"""Dt#"WU)xru+Sr+",533cH32ch23chhhXehTEex""bX"3rX3C7S5CURB%WSTS3CuU$WU3SR545#2"""'""DF$#$f#$DG2C3C%U%%Us3#U5%R&"""UDETDUUDFTD523E3(575(22r("44r"RSs4R#b4""d4'RcW#SW'2"3SB34"T$uWSCeV33uWR"EVURevuvD"$e6#3e633"rvy2#"""RRUUU6rBD$"5%TBUuT+"""2#X%%"HUUu'xcHBD$E25"E$45uW33eV"%uW%UeTWghVB"Dg32cV33cV32##4W%W3u"r$W$S22%"UU%$u3"#7%2"Ru5r"$"2E"UU(%""#"2C"4Bq'"2S(""%Ru""""R%25"B4"QC#%UCR'7$"2CX#77B"UU2$57"#R5%RR5e6s3E52#u52#UW#3fS2T82cfvUUTUEdUbd2UTT""2t23"t3$rU2eUffv6U"REF&eFEE%#G#""G"3#U'B3UV(#cgffT%"U37cV2#ST2#SW32uU#5xf#hEUgf6TUEUB##B%"'2r21$RRQ"C2a$'Q"%$XQqqQaQqQqqQ1B#3'5A32QQQ"A"a'%QU(R!"""R!!"311"R"2R!%!"13#!1%!3313!13"31"1""""UR""!"Q3!!b""RR%" I I I!!!3#31!11!1331!3#33331!11333311RQ"1 I I II I II  ޻ xxhxWgffffffffffeUVUUU553311""%%1QQ"3"1311"3#1111!21!13133#!1R1113! I ޻ ޻ 뛉໎Ј fUUVU5VUUXUUhUU5365333333U53Ue3UeUce"!"!!3!113""1!!2333113R!"3S1#31111"#3131!1!1!Q""3!!3!!!%"! I   DMMD ` `wpwwzwwwzwpwwwwwwww`f```ffʚiʚiDDDDIDD D D PWPyPyYT$t%t%T%PuUttC@tW ppW f`MMD D fffl`fnnf` ` wwwpwwwwpwwpwwwzz`````fiiiff @@DD  DDPyPyPwtwwwUUUD%X wwWX\E WU D@@@@@@@`|yUwwwwtUU 33 t t2 yuI33# MD @D@ @ @ @ DIID wwwwwwwww wwwwqwwwqwwwqwwwwwwwww ppzpzpUU@zݫXݫXX(PU%MD @ @ @ @ @D IID D  ` 3b63I"0Sd 2` 0r(T"3'SR#"0sw0"i%6 (0)VRI08RM"00 B$@ S0R 4"#Rg9"@0`#0J& Mc) #6""h 9 4 f0 g@@t$("C}"0@'@Rj#7R0'`@$ bJ" s3T  #@ UD 2lBrbBR6&"Cw"&yDBc&$""#"R$"$"I5ª%ۙdhCSHi9B$="zBICb&DD$b2Dt'D4"tvG݊I,ݪf&I$S432i'Tf$$RCC##&BBCH8$ Gp$007c &3E2 #0Bm2C$2d+Es%v7bbC0 R C`""GC2 2B`rFy4$v"x#7DF$b6&6$tLbiv84)$tiK{Gj""92C&(cxi2#ݛ&"BH3eFb8&M8"-X#)+ĂDV6Y"I"dBb#d4)(&$$"rDHRhy#$2"s""&B#"0" btD'(r "j@\ F2 " ,% })r'"& V"""x&d"@  $ D}9 G, ""2`G0f"@+"rR R"633%#'2BW#&""JDr" ""%&B4TD;fWJ#)$6RDR32K""B{&"sF5"2d$$d7BD"ec&"i52"3D6MCf*H#*F$*GD)yFfg9)rBfx4#9"62B[(%+2DcgDTbHrbsJB#yZ"bD$"B"2$B29&E44"'#"%rS"'Is5;@4 Y & -3 0" -&7` 03zdfxicVr$h%IW$&hE$xE6VTbF$9cܙ"B6cm)R"2ds"IFyDwgwxfwwf9"i$%V#"we"2E4R#"eF3sB%&$0)%"7s}#wD)")) P"( B#R2Br "7 'c %V5i5I'td&bke#$32"3#"$%#$Dw#Sb$'#F$)dz"6"h$#F37$"vh#"C22"7"2y)Cf"tD"d2Cׂ##R##s#344$3"f$#ceK42#t(3#'%$F$$fR#C##'VB2522$"&#34)#"`"s0i2#c=r3'C g2"F"xf""2#d2f5f%ff2(}%d'Bb"3K#B6""I#"""GE՝f"Gc+Bݛ&ww%$"623I2"$"$"g43Cxff6wfD$R#"""""#"lj#"fh2#SH6#hC3RD%"r3""c"B#Bv6"By42492)3k#B;MB"u&2"d72"C"22ej"$2u8"U$"c"" +P*PMPb &""`d`P0H"bmt%#"&D8"""d"""9&T) &b4 SP&{4cG8#c-h"bH2$BF2R"H%R"4rG33#4#e"2"TR# $Dh"RT ""&"""2g""bD$2"y"V0S 3$"" b@b`R`6i$BX{$&)$"2"""3}4 i&""Sj623b#BDhV$"2#6#E"2D$2d%2"BF ""0P B bB  @$#" B3")$C4"#" @$ y$٩6 <'-&$b04"&L@)}J6`tpspc`C0) DٛFݚfhFVDxF$ugT#3)%ur;b474b+D32"%"BRgs٩Rt%"HiidCzjc546"6utSCһFښGܙGyFwDwxFTcVD423eB$'42 $E2"$"B#Ft"tf$Hv)H#iVbY9*J"3chR":("7'"8("+'"9+'"""""#"""""""""""""""""""""""""""^7WR"R"$"%"%g":)e"V%$"S"R)9W"X"#"9WR6"3PS GPWw6PvP3$0pW#w"R7'r8wusw#twH"vrD#Vrs"Rrt#"""##"""""""""""R""T%"U%R_z*-2"$~""*"st*"r73ss%w; '6\'PrP ;XU+7%?y2/wr2r's•X'WR'X)R%%jZj*efZrwwS{RR"_Rr.eWR:)(%U''%u%XWRWR%%+"R2R%%`&0rP <PX ^w2"ys2'2/(5wXSxW'TW{UR"R"''fVf&gjWUZZ%"RrRiW"_#'%/)(%8'W(WXRr$+"W"Y"RP2p%0 5 $0P%%R s&p)"55'%G2rt2rr'&tBt$Be""B2Zu*rWU__/yO.W\;7R{vjYy"u&6U"""&(_'w'ZXv[su'Wkr$t%7uR'TKu%B&[@ @ @ P[WR{T#Y%RW5R#%t ) sTW%"""RUU%R)"2%U"rZzp wR"U"%*"Rr[r"$"% c7  XU[PU%`U U%RW(R5$u 7{tUX%R""2%U"rZzp wU$W"%rZr"#"% T7  P|P#20wPUPI &R"z2"r4B$0"TTXR"r%"TB'@0w3$&Ru"C@P zu`Pw0 pu'&bi{B7&u;*{%&rY)%B7{B*R&w7FrT'C 2tPpw`:uRP7u%&'G\bw%"bתj%prz0)22 bi*RwG{e'$B"p p0 v")B'g"p7յ b" psv 5i0'04@5{&Cy){E+24KB0"00g7p@'$rD"""&)$t|"%'7b{')'.r#.7',{GzrvFriwg)"22rWgj""u"$*"SWTyG%C|USR2YE'"u$&Dt)%VRW07|)"0'$%rd+'r-tr"B{"zwwv"W")"Grb7b7 zWb%W4"Rg*%Ri"*27"t yd& F ds V0G5PtDVT(4RBCS"w*0rP09w&siP0PP 3JP7U'":Vr7 Z0RBc$P"3R"$j%Rpyg" `%0$"%BrGB$@r@w PG'p;)\w"RUy"sw*""''"5%"t*'""""""""""""""""""T$R$""VR%'RR'R""&Rr""u%i"G&u"J)%R#W'UW"U"$"R7@ wrrr0yrr6PRw%"ew$TG"&"""""""""""0"""&"Ry'Y"t/_/""//9"".v"""y"""""""BuVypIvp#vRzw"y4 &F752wRT66U'7'U`crp& g0P`y 506D@r_ //p*  Wb2$&"% P` 0u'R" 6Bp59'sw%75"TcR%@'$`'uPuzp7[Pr@ ;i7+'%.2/'rBr'r9'WRpW)u%%jZj*efªĢJ"v%zS"_Rr.UfRK))&U'&%t%gWb'R%%""BR B&` 5`{sE Yyw%p\W@W00#R%'RUbf#Bgrp70'"z0"""R""Leb*j")/"|",b'w$RrW"i%wrw"wwerW'd""$Efb"gr3Rw5 7rw7s7C f0"G%BWU"w{VIg G0GpRp/j`bZyF5" 3"  z9`&02Y%c2 5C20WjDPs#7ZpBp0U {UrI%SY5rR%t SU$ "Rd% 3 rb"U2&("B$" GPz" ppp5p|i7pR7bPbl07tsbʐV#bF"R%0e%B3"` 2 s'pc[P"40 R` r2'bpZt{ |70F0wPr $s0 c0pP@ `k'pY" 3c3 ""`0 p[5P`@b@ jY V``@@ipp&i&09{*`GPx΋7'wk*{z 6p7g#w5 kv8`IV03PUP0`&rg 0R30@9``P)P jGVwzw{)x{+K&)LbV$cgcf{69`g˜Wrv{ ft{z'`6rPtrG$rT"y'r)y%&|bG)dw42'zbr$)WH344c7;{Uty'S5{2t4"s*%7RBUSSz54 Uf2t{TF Pu pPf P%p:Sr 00z'B0#"Y0f0')P@`0KrZGrr#r)pwrPit vg('F'9&Bz$ⓢ6G'w$2249)'G7*Hu|RB(e$&bVdB%RRfR$:5$0U"zVww{0ycv E%r#DGR{d'=Y%/%.y$"w'"U("U(#____--?=/$/.""""#"""""""""""RGTD"F'/77/GWd>7wR"wv$2t3"u5F{5uFB"$"$BEfk$wSgUfPY0 "bvi@R@02P0wI%5dPz&R`$73`0?=_]-///X;eeE67""RUU#s{"%$&Ssd377Bc$FFS$2FB'$b"tW`{gy$`'zw%s{7"Y7)eyb/TutB7 i7P7uEcR"R"''fVf&gjť#yZZyRG6wbyF%?7$nWe2dC$td%WVtDR%WD2$%2"UVs72ǜ $tپ euPp0@p0 P`0b& `|{k$"X# 4HWG#2u%RB4"`UpG8Br{#SW0}@P0I|l0,8H L (U(0b08i8HyHYDmY}iu։b {{fjjwww{pqowwq{qwaj`fajjjffjjjjjfjfjjjwf{wwwaw{wwjjwjowwafawwwwaaafafafaaffjfjfjjfffffafaafajffaaaaaaaw{w{ww{wwwffwajwwffffffffjaffjffjjqfqqwwqwqww{wq{fqfafafffjjfjjffffjfajfawwwqww{{qwwwwwaw{afaafjffjfffffawwq{qqjfafvww{wq{fvfw{qwww{w{{aaQUfffjfaffaafffjfaffajfjaafaajjfwqwqqqqw{q{qwq{{a{affafaojjjaajafafaajqqjfqfqwqqwqwwwqqqf{aqfaafaaafajjffqqq{qtUXwDPAIA!%RQq|QTFAtEwwwq{w{aQaWDe[Q'q[*Hq!{~q'QAQ|tQ\a!DrkQdQDfQddvqtaq!aoYVGAATaafaqqajaafwqwWw{q{qwqqQVLqq\הaIMQaL!Iq[\lJEAqQKq$TItA}!$QqIAjGALQAqDDQtUXwDPAIA!%RQq|QTFAtEQAUuqQwWaYUFfaQ aUDAFApa@ETAaawq{waaaqjqqaqqqqj{f{aff{jjfjA!jw~a\E,Uuq\AQF+A~$E{AKQE|WLgK{!EqQ!)aAtIq%aQaWDeQQ*Hq!AUQAU!ArkQaUqW"fdqqtaq!aoaWAq{A qA QQAQaqL_Qwwwqqqqqqqfajafaafq{qwqqqaQQ!afGqqQ/!LBHGAqqQAa)A[qA!AQaafQVLq\הaIQaL!IqYYAUYQtYq!AqIAjGAYAQAQQVqaAQa! @qAjAlAqDDQaaaajjfjaaqJ!fafYAQ񡡡ajafjjffjaajaa~a\E,Uuq\AQF+~!E{AKQEWL{A{)qA%~a\E,uqAQFA~AQQAqLKAAQ)dAqq!jafafaajjffaaaaaQQ!afGq1Q/!AqqQAa)A[qA!AQaafaQQ!afq/AaA[A!AQaaffaaaafaaaffaafjjffaafajjfjaaqJ!fafYAQ񡡡ajafjjffjaajaaajjfjaaqJ!fafYAQ񡡡ajafjjffjaajaa -- -- title: Dungeon of Innsmouse -- author: nan618 -- desc: Demo of a dungeon crawler game using raycasting engine by Wojciech Graj -- Debug_level = 0 --set 0 or nil to disable debug mode (start from title screen) Entity = { pos_x = 0, pos_y = 0, dir_x = 0, dir_y = -1, plane_x = 0.8, plane_y = 0, speed_rot = 0, speed_move = 0, } Entity.__index = Entity function Entity.new(pos_x, pos_y, speed_rot, speed_move) local self = setmetatable({}, Entity) self.pos_x = pos_x self.pos_y = pos_y self.speed_rot = speed_rot self.speed_move = speed_move return self end function Entity:rotate(delta) local speed = self.speed_rot * delta local old_dir_x = self.dir_x local math_cos = math.cos local math_sin = math.sin self.dir_x = self.dir_x * math_cos(speed) - self.dir_y * math_sin(speed) self.dir_y = old_dir_x * math_sin(speed) + self.dir_y * math_cos(speed) local old_plane_x = self.plane_x self.plane_x = self.plane_x * math_cos(speed) - self.plane_y * math_sin(speed) self.plane_y = old_plane_x * math_sin(speed) + self.plane_y * math_cos(speed) end function Entity:move(delta) local speed = self.speed_move * delta local math_floor = math.floor if mget(math_floor(self.pos_x + self.dir_x * speed), math_floor(self.pos_y)) == 0 then self.pos_x = self.pos_x + self.dir_x * speed end if mget(math_floor(self.pos_x), math_floor(self.pos_y + self.dir_y * speed)) == 0 then self.pos_y = self.pos_y + self.dir_y * speed end end Player = { } Player.__index = Player setmetatable(Player, Entity) function Player.new(pos_x, pos_y) local self = setmetatable({}, Player) self.pos_x = pos_x self.pos_y = pos_y self.target_x = pos_x self.target_y = pos_y self.start_x = pos_x self.start_y = pos_y self.current_angle = -math.pi / 2 self.target_angle = -math.pi / 2 self.start_angle = -math.pi / 2 self.dir_x = math.cos(self.current_angle) self.dir_y = math.sin(self.current_angle) self.plane_x = -self.dir_y * 0.8 self.plane_y = self.dir_x * 0.8 self.state = "IDLE" self.timer = 0 self.move_duration = 250 self.turn_duration = 200 self.cooldown_duration = 100 self.bump_duration = 150 -- Player Stats self.hp = 8 self.ammo = 6 self.stock = 2 self.keys = 0 self.magic_orb_timer = 0 self.msg = nil self.msg_timer = 0 return self end function Player:process(delta) if self.on_win then return end local math_cos = math.cos local math_sin = math.sin local math_floor = math.floor if self.state == "IDLE" then if btn(0) then local dx = math_cos(self.target_angle) local dy = math_sin(self.target_angle) local nx = self.pos_x + math_floor(dx + 0.5) local ny = self.pos_y + math_floor(dy + 0.5) if is_walkable(math_floor(nx), math_floor(ny)) then self.target_x = nx self.target_y = ny self.start_x = self.pos_x self.start_y = self.pos_y self.state = "MOVING" self.timer = 0 else self.target_x = self.pos_x + math_floor(dx + 0.5) * 0.15 self.target_y = self.pos_y + math_floor(dy + 0.5) * 0.15 self.start_x = self.pos_x self.start_y = self.pos_y self.state = "BUMPING" self.timer = 0 end elseif btn(1) then local dx = math_cos(self.target_angle) local dy = math_sin(self.target_angle) local nx = self.pos_x - math_floor(dx + 0.5) local ny = self.pos_y - math_floor(dy + 0.5) if is_walkable(math_floor(nx), math_floor(ny)) then self.target_x = nx self.target_y = ny self.start_x = self.pos_x self.start_y = self.pos_y self.state = "MOVING" self.timer = 0 else self.target_x = self.pos_x - math_floor(dx + 0.5) * 0.15 self.target_y = self.pos_y - math_floor(dy + 0.5) * 0.15 self.start_x = self.pos_x self.start_y = self.pos_y self.state = "BUMPING" self.timer = 0 end elseif btn(2) then self.target_angle = self.current_angle - math.pi / 2 self.start_angle = self.current_angle self.state = "TURNING" self.timer = 0 elseif btn(3) then self.target_angle = self.current_angle + math.pi / 2 self.start_angle = self.current_angle self.state = "TURNING" self.timer = 0 elseif btnp(4) then local dx = math_cos(self.target_angle) local dy = math_sin(self.target_angle) local dir_x = math_floor(dx + 0.5) local dir_y = math_floor(dy + 0.5) local front_x = math_floor(self.pos_x) + dir_x local front_y = math_floor(self.pos_y) + dir_y local tile = mget(front_x, front_y) local def = g_TILE_DEFS[tile] if def and def.type == "DOOR" then mset(front_x, front_y, 0) elseif def and def.type == "EXIT" then if g_player.keys > 0 then advance_level() else g_player.msg = "You need a key!" g_player.msg_timer = 2000 end end end elseif self.state == "BUMPING" then self.timer = self.timer + delta local t = self.timer / self.bump_duration if t >= 1 then t = 1 self.state = "COOLDOWN" self.timer = 0 end local bump_t = math.sin(t * math.pi) self.pos_x = self.start_x + (self.target_x - self.start_x) * bump_t self.pos_y = self.start_y + (self.target_y - self.start_y) * bump_t elseif self.state == "MOVING" then self.timer = self.timer + delta local t = self.timer / self.move_duration if t >= 1 then t = 1 self.state = "COOLDOWN" self.timer = 0 -- Check item pickup: collect any nearby item sprites for _, sp in ipairs(g_sprites) do if sp.item_type then local dx = sp.pos_x - self.target_x local dy = sp.pos_y - self.target_y if dx*dx + dy*dy < 0.64 then -- pickup radius ~0.8 tiles if sp.item_type == "heart" then g_player.hp = math.min(10, g_player.hp + 1) elseif sp.item_type == "bullet" then g_player.stock = g_player.stock + 1 elseif sp.item_type == "key" then g_player.keys = g_player.keys + 1 elseif sp.item_type == "magic_orb" then g_player.magic_orb_timer = 60000 end sp.collected = true -- Permanently hide sprite sp.item_type = nil -- Prevent double-pickup end end end local tile = mget(math_floor(self.target_x), math_floor(self.target_y)) local def = g_TILE_DEFS[tile] if def and (def.type == "ENEMY" or def.type == "STRIKER_ENEMY") then local encounter_chance = def.chance or 0.8 if math.random() < encounter_chance then local mx, my = math_floor(self.target_x), math_floor(self.target_y) local e_type if def.type == "STRIKER_ENEMY" then e_type = "STRIKER" else local r = math.random() if r < 0.5 then e_type = "SLIME" else e_type = "IMP" end end Game:change_state(State_Combat, { enemy_type = e_type, map_x = mx, map_y = my }) end end end self.pos_x = self.start_x + (self.target_x - self.start_x) * t self.pos_y = self.start_y + (self.target_y - self.start_y) * t elseif self.state == "TURNING" then self.timer = self.timer + delta local t = self.timer / self.turn_duration if t >= 1 then t = 1 self.state = "COOLDOWN" self.timer = 0 self.current_angle = self.target_angle else self.current_angle = self.start_angle + (self.target_angle - self.start_angle) * t end self.dir_x = math_cos(self.current_angle) self.dir_y = math_sin(self.current_angle) self.plane_x = -self.dir_y * 0.8 self.plane_y = self.dir_x * 0.8 elseif self.state == "COOLDOWN" then self.timer = self.timer + delta if self.timer >= self.cooldown_duration then self.state = "IDLE" self.timer = 0 end end end Sprite = { pos_x = 0, pos_y = 0, tex_id = 0, scl_horiz = 1, scl_vert = 1, offset_vert = 0, --from 0.5 (floor) to -0.5 (ceiling) screen_offset_vert = 0, dist = 0, --Distance to player (negative if not in viewing triangle) screen_x = 0, SCREEN_WIDTH = 0, SCREEN_HEIGHT = 0, draw_start_y = 0, draw_end_y = 0, draw_start_x = 0, draw_end_x = 0, } Sprite.__index = Sprite function Sprite.new(pos_x, pos_y, tex_id, scl_horiz, scl_vert, offset_vert) local self = setmetatable({}, Sprite) self.pos_x = pos_x self.pos_y = pos_y self.tex_id = tex_id self.scl_horiz = scl_horiz self.scl_vert = scl_vert self.offset_vert = offset_vert return self end function Sprite:process(inv_det) if self.collected then self.dist = -1; return end -- Skip collected items local SCREEN_WIDTH = g_SCREEN_WIDTH local SCREEN_HEIGHT = g_SCREEN_HEIGHT local SCREEN_HALF_HEIGHT = SCREEN_HEIGHT / 2 local player = g_player local math_abs = math.abs local rel_x = self.pos_x - player.pos_x local rel_y = self.pos_y - player.pos_y self.dist = math.sqrt(rel_x * rel_x + rel_y * rel_y) local trans_y = inv_det * (player.plane_x * rel_y - player.plane_y * rel_x) if trans_y <= 0 then self.dist = -self.dist return end local trans_x = inv_det * (player.dir_y * rel_x - player.dir_x * rel_y) self.screen_x = math.floor((SCREEN_WIDTH * 0.5) * (1 + trans_x / trans_y)) self.SCREEN_WIDTH = math_abs(SCREEN_HEIGHT // trans_y) // self.scl_horiz self.draw_start_x = self.screen_x - self.SCREEN_WIDTH // 2 if self.draw_start_x < 0 then self.draw_start_x = 0 elseif self.draw_start_x >= SCREEN_WIDTH then self.dist = -self.dist return end self.draw_end_x = self.screen_x + self.SCREEN_WIDTH // 2 if self.draw_end_x >= SCREEN_WIDTH then self.draw_end_x = SCREEN_WIDTH - 1 elseif self.draw_end_x < 0 then self.dist = -self.dist return end self.SCREEN_HEIGHT = math_abs(SCREEN_HEIGHT // trans_y) // self.scl_vert self.screen_offset_vert = SCREEN_HALF_HEIGHT * self.offset_vert // trans_y self.draw_start_y = SCREEN_HALF_HEIGHT - self.SCREEN_HEIGHT // 2 + self.screen_offset_vert if self.draw_start_y < 0 then self.draw_start_y = 0 end self.draw_end_y = SCREEN_HALF_HEIGHT + self.SCREEN_HEIGHT // 2 + self.screen_offset_vert if self.draw_end_y >= SCREEN_HEIGHT then self.draw_end_y = SCREEN_HEIGHT - 1 end end function get_tex_pixel(offset, id, x, y) return peek4(offset + 0x40 * (id + 16 * (y // 8) + x // 8) + 0x8 * (y % 8) + (x % 8)) end function is_walkable(x, y) local tile = mget(x, y) if tile == 0 then return true end local def = g_TILE_DEFS[tile] if def and (def.type == "WALL" or def.type == "DOOR" or def.type == "EXIT") then return false end return true end function load_map(level_idx) local level = g_LEVELS and g_LEVELS[level_idx] or {x=0, y=0} local ox, oy = level.x, level.y g_sprites = {} local spawn_found = false for dy = 0, 16 do for dx = 0, 29 do local x, y = ox + dx, oy + dy local tile = mget(x, y) local def = g_TILE_DEFS[tile] if def then if def.type == "SPAWN" then g_player.pos_x = x + 0.5 g_player.pos_y = y + 0.5 g_player.target_x = g_player.pos_x g_player.target_y = g_player.pos_y g_player.start_x = g_player.pos_x g_player.start_y = g_player.pos_y -- BUG-07: explicitly reset state machine so level entry is always clean g_player.state = "IDLE" g_player.timer = 0 mset(x, y, 0) spawn_found = true elseif def.type == "ITEM" then local s_id = def.sprite_id or 0 local sp = Sprite.new(x + 0.5, y + 0.5, s_id, 2, 2, 0.5) sp.item_type = def.item_type g_sprites[#g_sprites+1] = sp mset(x, y, 0) end end end end if not spawn_found then g_player.pos_x = ox + 1.5 g_player.pos_y = oy + 1.5 g_player.target_x = g_player.pos_x g_player.target_y = g_player.pos_y g_player.start_x = g_player.pos_x g_player.start_y = g_player.pos_y -- BUG-07: explicitly reset state machine so level entry is always clean g_player.state = "IDLE" g_player.timer = 0 end end function init() sync(4, 0, false) -- Reload map from cart g_SCREEN_WIDTH = 240 g_SCREEN_HEIGHT = 136 g_DEBUG = false g_TEX_WIDTH = 32 --texture width in pixels g_TEX_HEIGHT = 32 --texture height in pixels g_SPRITE_SIZES = { [174]={16,16}, -- Magic Orb [218]={16,16}, -- Heart [214]={16,16}, -- Bullet [216]={16,16}, -- Key } g_TILE_DEFS = { [0] = { type = "GROUND", floor = 20, ceiling = 24 }, [1] = { type = "WALL", tex_id = 16, floor = 144 }, [2] = { type = "DOOR", tex_id = 28 }, [3] = { type = "SPAWN" }, [4] = { type = "EXIT", tex_id = 80 }, [5] = { type = "ITEM", sprite_id = 216, item_type = "key" }, -- Key [6] = { type = "ITEM", sprite_id = 218, item_type = "heart" }, -- Heart [7] = { type = "ITEM", sprite_id = 214, item_type = "bullet" }, -- Bullet [8] = { type = "ITEM", sprite_id = 174, item_type = "magic_orb" }, [9] = { type = "ENEMY", floor = 20, ceiling = 24, chance = 0.8 }, [10] = { type = "WALL", tex_id = 84, floor = 144 }, [11] = { type = "GROUND", floor = 144, ceiling = 24 }, [12] = { type = "STRIKER_ENEMY", floor = 20, ceiling = 24, chance = 0.7 }, } g_LEVELS = { {x=0, y=0}, -- Level 1 {x=30, y=0}, -- Level 2 {x=60, y=0}, -- Level 3 } g_current_level = (Debug_level and Debug_level > 0) and Debug_level or 1 g_player = Player.new(1.5, 1.5) -- temporary, overwritten by load_map load_map(g_current_level) g_prev_time = 0 g_settings = { floor_ceil = true, interlace = 2, --disabled=g_interlace>=2 } end init() Game = { state = nil } function Game:change_state(new_state, params) if self.state and self.state.exit then self.state:exit() end self.state = new_state if self.state and self.state.enter then self.state:enter(params) end end function advance_level() g_player.keys = g_player.keys - 1 g_player.magic_orb_timer = 0 local next_level = g_current_level + 1 if g_LEVELS[next_level] then -- Transition to next level g_player.msg = "LEVEL " .. next_level g_player.msg_timer = 2000 g_player.on_win = true -- Use on_win to black out screen and block input g_player.next_level_to_load = next_level else -- No more levels: show win message then reset g_player.msg = "You Survived!" g_player.msg_timer = 2000 g_player.on_win = true g_player.next_level_to_load = nil end end State_Death = { timer = 0 } function State_Death:enter(params) self.timer = 0 self.prev_state = params and params.prev_state end function State_Death:process(delta) self.timer = self.timer + delta if self.timer >= 2000 then Game:change_state(State_GameOver) end end function State_Death:draw(delta) -- Flicker every 66ms (approx 4 frames at 60fps) if math.floor(self.timer / 66) % 2 == 0 then cls(3) -- Red flash else -- Draw the previous scene (dungeon/monsters) if self.prev_state and self.prev_state.draw then self.prev_state:draw(delta) end end end State_GameOver = { timer = 0 } function State_GameOver:enter() self.timer = 0 if g_player then g_player.magic_orb_timer = 0 end end function State_GameOver:process(delta) self.timer = (self.timer + delta) % 1000 if btnp(4) then init() Game:change_state(State_Title) end end function State_GameOver:draw(delta) cls(1) local msg1 = "GAME OVER" local x1 = 120 - (#msg1 * 6) -- scale 2 print(msg1, x1, 50, 2, false, 2) local msg2 = "Press Z to Return to Title" if self.timer < 500 then local w = print(msg2, 0, -20) local x2 = (240 - w) // 2 print(msg2, x2, 100, 7) end end State_Title = { timer = 0 } function State_Title:enter() self.timer = 0 end function State_Title:process(delta) self.timer = (self.timer + delta) % 1000 if btnp(4) then Game:change_state(State_Explore) end end function State_Title:draw(delta) cls(1) print("DUNGEON", 95, 30, 12) print("OF", 110, 40, 12) print("INNSMOUSE", 65, 50, 12, false, 2) local msg = "Press Z to start" if self.timer < 500 then -- Center the text: 240 / 2 - (chars * 6 / 2) local x = 120 - (#msg * 3) print(msg, x, 100, 7) end end State_Explore = {} function State_Explore:process(delta) if g_player.magic_orb_timer > 0 then g_player.magic_orb_timer = math.max(0, g_player.magic_orb_timer - delta) end g_player:process(delta) if g_player.msg_timer > 0 then g_player.msg_timer = g_player.msg_timer - delta if g_player.msg_timer <= 0 and g_player.on_win then if g_player.next_level_to_load then -- Load next level g_current_level = g_player.next_level_to_load g_player.on_win = false g_player.msg = nil g_player.next_level_to_load = nil load_map(g_current_level) else init() -- Reset entire game after final Win Game:change_state(State_Title) end end end end function State_Explore:draw(delta) if g_player.on_win then cls(1) return end local t = time() local SCREEN_WIDTH = g_SCREEN_WIDTH local SCREEN_HEIGHT = g_SCREEN_HEIGHT local SCREEN_HALF_HEIGHT = SCREEN_HEIGHT // 2 local FOG_DENSITY = 0.6 local SCREEN_HALF_HEIGHT = SCREEN_HEIGHT // 2 local TEX_WIDTH = g_TEX_WIDTH local TEX_HEIGHT = g_TEX_HEIGHT local SPRITE_SIZES = g_SPRITE_SIZES local TEX_MAP = g_TEX_MAP local player = g_player local sprites = g_sprites local settings = g_settings local math_floor = math.floor local math_abs = math.abs local start_vline local step_vline if settings.interlace >= 2 then start_vline = 0 step_vline = 1 cls(0) else start_vline = (settings.interlace + 1) % 2 settings.interlace = start_vline step_vline = 2 for x=start_vline,SCREEN_WIDTH-1,step_vline do for y=0,SCREEN_HEIGHT-1 do pix(x, y, 0) end end end -- game logic (moved to process) local t_temp = time() local t_logic = 0 t = t_temp -- drawing local inv_det = 1 / (player.plane_x * player.dir_y - player.dir_x * player.plane_y) local visible_sprites = {} for key,sprite in pairs(sprites) do sprite:process(inv_det) if sprite.dist > 0 then visible_sprites[#visible_sprites+1] = sprite end end table.sort(visible_sprites, function(a,b) return a.dist < b.dist end) local num_visible_sprites = #visible_sprites -- draw walls and sprites local start_x = 24 if settings.interlace < 2 then start_x = 24 + start_vline end for x=start_x, 215, step_vline do local camera_x = 2 * (x - 24) / 192 - 1 local ray_dir_x = player.dir_x + player.plane_x * camera_x local ray_dir_y = player.dir_y + player.plane_y * camera_x local map_x = math_floor(player.pos_x) local map_y = math_floor(player.pos_y) local delta_dist_x = math_abs(1 / ray_dir_x) local delta_dist_y = math_abs(1 / ray_dir_y) local step_x local side_dist_x if ray_dir_x < 0 then step_x = -1 side_dist_x = (player.pos_x - map_x) * delta_dist_x else step_x = 1 side_dist_x = (map_x + 1.0 - player.pos_x) * delta_dist_x end local step_y local side_dist_y if ray_dir_y < 0 then step_y = -1 side_dist_y = (player.pos_y - map_y) * delta_dist_y else step_y = 1 side_dist_y = (map_y + 1.0 - player.pos_y) * delta_dist_y end local current_sprite = 1 local not_hit_full_wall = true local prev_draw_start = SCREEN_HEIGHT while not_hit_full_wall do -- Get next wall tile using DDA local side local tile_data while true do if side_dist_x < side_dist_y then side_dist_x = side_dist_x + delta_dist_x map_x = map_x + step_x side = 0 else side_dist_y = side_dist_y + delta_dist_y map_y = map_y + step_y side = 1 end if map_x < 0 or map_x >= 240 or map_y < 0 or map_y >= 136 then tile_data = 1 -- Treat boundary as wall break end tile_data = mget(map_x, map_y) local def = g_TILE_DEFS[tile_data] if def and (def.type == "WALL" or def.type == "DOOR" or def.type == "EXIT") then break end end local perp_wall_dist if side == 0 then perp_wall_dist = (map_x - player.pos_x + (1 - step_x) * 0.5) / ray_dir_x else perp_wall_dist = (map_y - player.pos_y + (1 - step_y) * 0.5) / ray_dir_y end --draw sprites for sprite_idx=current_sprite,num_visible_sprites do local sprite = visible_sprites[sprite_idx] if sprite.dist >= perp_wall_dist then break end current_sprite = sprite_idx + 1 if x >= sprite.draw_start_x and x <= sprite.draw_end_x then local fog_amount = math_floor(sprite.dist * FOG_DENSITY) local sprite_size = SPRITE_SIZES[sprite.tex_id] local a = sprite_size[2] / sprite.SCREEN_HEIGHT local sprite_tex_x = math_floor((x - (sprite.screen_x - sprite.SCREEN_WIDTH / 2)) * sprite_size[1] / sprite.SCREEN_WIDTH) % sprite_size[1] for y=sprite.draw_start_y,sprite.draw_end_y do local tex_y = math_floor((y - (sprite.screen_offset_vert or 0) - SCREEN_HALF_HEIGHT + sprite.SCREEN_HEIGHT / 2) * a) % sprite_size[2] local color = get_tex_pixel(0x8000, sprite.tex_id, sprite_tex_x, tex_y) if color > 0 and pix(x, y) == 0 then pix(x, y, color > fog_amount and (color - fog_amount) or 1) end end end end --draw wall not_hit_full_wall = false local line_height = SCREEN_HEIGHT // perp_wall_dist local draw_start = SCREEN_HALF_HEIGHT - line_height // 2 + 1 if draw_start < 0 then draw_start = 0 elseif draw_start >= SCREEN_HEIGHT then draw_start = SCREEN_HEIGHT - 1 end local draw_end = SCREEN_HALF_HEIGHT + line_height // 2 - 1 if draw_end > prev_draw_start then draw_end = prev_draw_start elseif draw_end >= SCREEN_HEIGHT then draw_end = SCREEN_HEIGHT - 1 end local wall_x if side == 0 then wall_x = player.pos_y + perp_wall_dist * ray_dir_y else wall_x = player.pos_x + perp_wall_dist * ray_dir_x end wall_x = wall_x - math_floor(wall_x) local tile_def = g_TILE_DEFS[tile_data] local tex_id = tile_def and tile_def.tex_id or 1 local tex_x = math_floor(wall_x * TEX_WIDTH) local step_tex = TEX_HEIGHT / line_height local testart_vline = (draw_start - SCREEN_HALF_HEIGHT + line_height * 0.5) * step_tex local fog_amount = math_floor(perp_wall_dist * FOG_DENSITY) for y=draw_start,draw_end do if pix(x, y) == 0 then local tex_y = math_floor(testart_vline + step_tex * (y - draw_start)) % TEX_HEIGHT local color = get_tex_pixel(0x8000, tex_id, tex_x, tex_y) pix(x, y, color > fog_amount and (color - fog_amount) or 1) end end prev_draw_start = draw_start end end t_temp = time() local t_wall_sprite = t_temp - t t = t_temp --draw floor + ceiling if settings.floor_ceil then local ray_dir_x0 = player.dir_x - player.plane_x local ray_dir_y0 = player.dir_y - player.plane_y local ray_dir_x1 = player.dir_x + player.plane_x local ray_dir_y1 = player.dir_y + player.plane_y for y=SCREEN_HALF_HEIGHT,SCREEN_HEIGHT do local row_distance = SCREEN_HALF_HEIGHT / (y - SCREEN_HALF_HEIGHT) local fog_amount = math_floor(row_distance * FOG_DENSITY) local floor_step_x = row_distance * (ray_dir_x1 - ray_dir_x0) / 192 local floor_step_y = row_distance * (ray_dir_y1 - ray_dir_y0) / 192 local start_x = 24 if settings.interlace < 2 then start_x = 24 + start_vline end local floor_x = player.pos_x + row_distance * ray_dir_x0 + (start_x - 24) * floor_step_x local floor_y = player.pos_y + row_distance * ray_dir_y0 + (start_x - 24) * floor_step_y floor_step_x = floor_step_x * step_vline floor_step_y = floor_step_y * step_vline for x=start_x, 215, step_vline do local tex_x = math_floor(TEX_WIDTH * floor_x) % TEX_WIDTH local tex_y = math_floor(TEX_HEIGHT * floor_y) % TEX_HEIGHT local cell_x = math_floor(floor_x) local cell_y = math_floor(floor_y) local tile = mget(cell_x, cell_y) local def = g_TILE_DEFS[tile] local fallback = g_TILE_DEFS[0] local floor_tex = (def and def.floor) or (fallback and fallback.floor) or 5 local ceil_tex = (def and def.ceiling) or (fallback and fallback.ceiling) or 4 --floor if pix(x, y) == 0 then local color = get_tex_pixel(0x8000, floor_tex, tex_x, tex_y) pix(x, y, color > fog_amount and (color - fog_amount) or 1) end --ceiling if pix(x, SCREEN_HEIGHT - y - 1) == 0 then local color = get_tex_pixel(0x8000, ceil_tex, tex_x, tex_y) pix(x, SCREEN_HEIGHT - y - 1, color > fog_amount and (color - fog_amount) or 1) end floor_x = floor_x + floor_step_x floor_y = floor_y + floor_step_y end end end t_temp = time() local t_floor = t_temp - t if g_DEBUG then print(string.format("FPS %d\n#SPR %d\nLOGIC %.1f\nWALL&SPR %.1f\nFLR&CEIL %.1f", math_floor(1000 / delta), num_visible_sprites, t_logic, t_wall_sprite, t_floor), 0, 0, 5) end end Slime = {} Slime.__index = Slime function Slime.new(base_x, base_y) local self = setmetatable({}, Slime) self.hp = 4 self.base_x = base_x self.base_y = base_y self.state = "APPEAR" self.timer = 0 self.attack_timer = 0 self.drop_y = -72 self.move_speed = 0.8 return self end function Slime:process(delta) self.timer = self.timer + delta if self.state == "APPEAR" then if self.timer < 700 then -- Accelerating drop from top of screen (700ms) local t = self.timer / 700 self.drop_y = -72 + (self.base_y + 72) * (t * t) elseif self.timer < 1200 then -- Landing sequence: 3 frames x ~166ms = ~500ms self.drop_y = self.base_y else -- Transition to MOVE, switch to Bank 0 sprites sync(2, 0, false) self.state = "MOVE" self.timer = 0 self.target_x = math.random(95, 145) end elseif self.state == "MOVE" then self.attack_timer = self.attack_timer + delta local dx = self.target_x - self.base_x local step = self.move_speed * (delta / 16.66) if math.abs(dx) > step then self.base_x = self.base_x + (dx > 0 and step or -step) else self.base_x = self.target_x self.target_x = math.random(95, 145) end if self.attack_timer >=1200 then self.state = "ATTACK" self.timer = 0 self.attack_timer = 0 end elseif self.state == "ATTACK" then -- 3 frames x 333ms = ~1000ms -- The smash / super armor phase starts at 666ms if self.timer >= 666 and (self.timer - delta) < 666 then trigger_effect("glitch", 200, 2) if g_player then g_player.hp = math.max(0, g_player.hp - 1) if g_player.hp <= 0 then Game:change_state(State_Death, {prev_state = Game.state}) end end end if self.timer >= 1000 then self.state = "MOVE" self.timer = 0 self.attack_timer = 0 end elseif self.state == "HURT" then -- 1 frame x 166ms = ~166ms if self.timer >= 400 then if self.hp <= 0 then -- Stay in Bank 1 for DIE self.state = "DIE" self.timer = 0 else -- Back to MOVE, switch to Bank 0 sprites sync(2, 0, false) self.state = "MOVE" self.timer = 0 end end elseif self.state == "DIE" then -- 4 frames x 300ms = 1200ms total if self.timer >= 1200 then if Game.state and Game.state.map_x then mset(Game.state.map_x, Game.state.map_y, 0) end Game:change_state(State_Explore) end end end function Slime:can_be_hit() -- BUG-04: super armor starts at 666ms into ATTACK return not (self.state == "ATTACK" and self.timer >= 666) end function Slime:hit_test(px, py) -- Slime: bottom-aligned on base_y; original hitbox (±16 wide, 48px tall) return math.abs(px - self.base_x) < 16 and py > self.base_y - 48 and py < self.base_y end function Slime:take_hit() -- Switch to Bank 1 sprites and transition to HURT or DIE sync(2, 1, false) self.hp = self.hp - 1 self.timer = 0 self.attack_timer = 0 self.state = self.hp <= 0 and "DIE" or "HURT" if self.state == "DIE" then trigger_effect("shake", 300, 2) end end function Slime:draw() local sp = 264 local w, h = 8, 9 local draw_y if self.state == "APPEAR" then if self.timer < 700 then -- Drop phase: W8H9 (700ms) sp, w, h = 264, 8, 9 draw_y = self.drop_y - (h * 8) elseif self.timer < 866 then sp, w, h = 352, 8, 4 draw_y = self.base_y - (h * 8) elseif self.timer < 1032 then sp, w, h = 256, 8, 6 draw_y = self.base_y - (h * 8) else sp, w, h = 408, 8, 7 draw_y = self.base_y - (h * 8) end elseif self.state == "MOVE" then -- 2 frames x ~333ms loop local frame = math.floor(self.timer / 333) % 2 sp = frame == 0 and 384 or 392 w, h = 8, 8 draw_y = self.base_y - (h * 8) elseif self.state == "ATTACK" then if self.timer < 333 then sp, w, h = 392, 8, 8 elseif self.timer < 666 then sp, w, h = 320, 8, 4 else sp, w, h = 264, 8, 8 end draw_y = self.base_y - (h * 8) elseif self.state == "HURT" then sp, w, h = 256, 8, 6 draw_y = self.base_y - (h * 8) elseif self.state == "DIE" then if self.timer < 300 then sp, w, h = 256, 8, 6 elseif self.timer < 600 then sp, w, h = 352, 8, 4 elseif self.timer < 900 then sp, w, h = 416, 8, 4 else sp, w, h = 480, 8, 2 end draw_y = self.base_y - (h * 8) end -- Bottom-left alignment: draw_x centers the sprite horizontally on base_x local draw_x = self.base_x - (w * 8) / 2 spr(sp, draw_x, draw_y, 0, 1, 0, 0, w, h) end Imp = {} Imp.__index = Imp function Imp.new(base_x, base_y) local self = setmetatable({}, Imp) self.hp = 3 self.base_x = base_x self.base_y = base_y self.state = "APPEAR" self.timer = 0 self.attack_timer = 0 self.scale = 5 -- Random speed between 1.1 and 1.5 self.move_speed = 1.0 + math.random() * 0.4 self.target_x = math.random(24, 215) self.target_y = math.random(20, 120) return self end function Imp:can_be_hit() -- BUG-04: invincibility during second half of ATTACK (430ms to 860ms) return not (self.state == "ATTACK" and self.timer >= 430) end function Imp:hit_test(px, py) -- BUG-05: Imp base_y is center point; sprite is 40x40px (5*8) at scale=1 -- Use half-extents of 20px on each axis for full coverage return math.abs(px - self.base_x) < 20 and py > self.base_y - 20 and py < self.base_y + 20 end function Imp:take_hit() -- Invincibility is now gated by can_be_hit() before this is called self.hp = self.hp - 1 self.timer = 0 self.attack_timer = 0 self.state = self.hp <= 0 and "DIE" or "HURT" if self.state == "DIE" then trigger_effect("shake", 300, 2) end end function Imp:process(delta) self.timer = self.timer + delta if self.state == "APPEAR" then if self.timer < 400 then -- Scale decreases linearly from 5 to 1 over 400ms self.scale = math.max(1, math.ceil(5 - 4 * (self.timer / 400))) else self.state = "MOVE" self.timer = 0 self.scale = 1 self.target_x = math.random(66, 170) self.target_y = math.random(35, 100) self.move_speed = 1.0 + math.random() * 0.4 end elseif self.state == "MOVE" then self.attack_timer = self.attack_timer + delta local dx = self.target_x - self.base_x local dy = self.target_y - self.base_y local dist = math.sqrt(dx*dx + dy*dy) if dist > 1 then self.base_x = self.base_x + (dx/dist) * self.move_speed self.base_y = self.base_y + (dy/dist) * self.move_speed else self.target_x = math.random(66, 170) self.target_y = math.random(35, 100) self.move_speed = 0.7 + math.random() * 0.2 end if self.attack_timer >= 900 then --imp attack timer self.state = "ATTACK" self.timer = 0 self.attack_timer = 0 end elseif self.state == "ATTACK" then -- 430ms wind-up, 430ms attack if self.timer >= 430 and (self.timer - delta) < 430 then trigger_effect("glitch", 200, 2) if g_player then g_player.hp = math.max(0, g_player.hp - 1) if g_player.hp <= 0 then Game:change_state(State_Death, {prev_state = Game.state}) end end end if self.timer >= 860 then self.state = "MOVE" self.timer = 0 self.attack_timer = 0 end elseif self.state == "HURT" then -- 500ms static frame if self.timer >= 500 then if self.hp <= 0 then self.state = "DIE" self.timer = 0 else self.state = "MOVE" self.timer = 0 end end elseif self.state == "DIE" then -- 4 frames x 200ms = 800ms total if self.timer >= 800 then if Game.state and Game.state.map_x then mset(Game.state.map_x, Game.state.map_y, 0) end Game:change_state(State_Explore) end end end function Imp:draw() local sp = 256 local w, h = 5, 5 local draw_scale = self.scale or 1 if self.state == "APPEAR" then sp = 256 draw_scale = self.scale elseif self.state == "MOVE" then -- 2 frames x 150ms loop local frame = math.floor(self.timer / 150 ) % 2 sp = frame == 0 and 256 or 261 elseif self.state == "ATTACK" then if self.timer < 430 then sp = 421 else sp = 426 end elseif self.state == "HURT" then sp = 266 elseif self.state == "DIE" then if self.timer < 200 then sp = 336 elseif self.timer < 400 then sp = 341 elseif self.timer < 600 then sp = 346 else sp = 416 end end -- Center alignment local draw_x = self.base_x - (w * 8 * draw_scale) / 2 local draw_y = self.base_y - (h * 8 * draw_scale) / 2 spr(sp, draw_x, draw_y, 0, draw_scale, 0, 0, w, h) end Striker = {} Striker.__index = Striker function Striker.new(base_x, base_y) local self = setmetatable({}, Striker) self.base_x = base_x self.base_y = base_y self.x = base_x self.y = 200 -- Start completely off-screen (136 + 64) self.state = "APPEAR" self.timer = 0 self.hp = 1 self.attack_timer = 300 self.speed = 0 return self end function Striker:can_be_hit() if self.state == "IDLE" then return true end if self.state == "ATTACK" and self.timer < 330 then return true end return false end function Striker:take_hit() self.state = "HURT" self.timer = 0 self.hp = self.hp - 1 end function Striker:hit_test(px, py) local hw = 32 local h = 64 return px >= self.x - hw and px <= self.x + hw and py >= self.y - h and py <= self.y end function Striker:process(delta) self.timer = self.timer + delta if self.state == "APPEAR" then local duration = 300 if self.timer >= duration then self.y = self.base_y self.state = "IDLE" self.timer = 0 self.attack_timer = 800 else local t = self.timer / duration self.y = 200 + (self.base_y - 200) * t end elseif self.state == "IDLE" then self.attack_timer = self.attack_timer - delta if self.attack_timer <= 0 then self.state = "ATTACK" self.timer = 0 end elseif self.state == "ATTACK" then if self.timer >= 330 and self.timer - delta < 330 then if g_player then g_player.hp = g_player.hp - 1 end trigger_effect("glitch", 200, 4) end if self.timer >= 660 then self.state = "ESCAPE" self.timer = 0 end elseif self.state == "ESCAPE" then local duration = 300 if self.timer >= duration then if Game.state and Game.state.map_x then mset(Game.state.map_x, Game.state.map_y, 0) end Game:change_state(State_Explore) else local t = self.timer / duration self.y = self.base_y + (200 - self.base_y) * t end elseif self.state == "HURT" then if self.hp <= 0 then self.state = "DIE" self.timer = 0 trigger_effect("shake", 300, 2) else self.state = "ESCAPE" self.timer = 0 end elseif self.state == "DIE" then if self.timer >= 660 then if Game.state and Game.state.map_x then mset(Game.state.map_x, Game.state.map_y, 0) end Game:change_state(State_Explore) end end end function Striker:draw() local draw_x = self.x - 32 local draw_y = self.y - 64 if self.state == "APPEAR" or self.state == "ESCAPE" then spr(384, draw_x, draw_y, 0, 1, 0, 0, 8, 8) spr(384, draw_x + 64, draw_y + 0, 0, 1, 1, 0, 4, 6) elseif self.state == "IDLE" then spr(384, draw_x, draw_y, 0, 1, 0, 0, 8, 8) spr(384, draw_x + 64, draw_y + 0, 0, 1, 1, 0, 4, 6) elseif self.state == "ATTACK" then local is_frame2 = self.timer >= 330 spr(384, draw_x, draw_y, 0, 1, 0, 0, 8, 8) if not is_frame2 then spr(262, draw_x + 64, draw_y - 16, 0, 1, 0, 0, 7, 6) else spr(272, draw_x + 32, draw_y + 0, 0, 1, 0, 0, 6, 7) end elseif self.state == "DIE" then local is_frame2 = self.timer >= 50 spr(384, draw_x, draw_y, 0, 1, 0, 0, 4, 6) spr(384, draw_x + 64, draw_y + 0, 0, 1, 1, 0, 4, 6) spr(484, draw_x + 32, draw_y + 48, 0, 1, 0, 0, 4, 2) if not is_frame2 then spr(408, draw_x+32, draw_y -8, 0, 1, 0, 0, 4, 7) else spr(412, draw_x+32, draw_y - 8, 0, 1, 0, 0, 4, 7) end end end PlayerCombat = {} PlayerCombat.__index = PlayerCombat function PlayerCombat.new() local self = setmetatable({}, PlayerCombat) self.x = math.random(65, 170) --crosshair init position self.y = math.random(40, 60) --crosshair init position self.ammo = g_player and g_player.ammo or 6 self.stock = g_player and g_player.stock or 2 self.cooldown = 0 self.state = "IDLE" self.timer = 0 self.hit_timer = 0 self.hit_x = 0 self.hit_y = 0 self.projectiles = {} self.speed = 1 -- crosshair movement speed return self end function PlayerCombat:process(delta, enemy) -- Timers and Reload should process even if enemy is nil (e.g. during death animation) if self.cooldown > 0 then self.cooldown = self.cooldown - delta end if self.hit_timer > 0 then self.hit_timer = self.hit_timer - delta end self.timer = self.timer + delta if self.state == "RELOAD" and self.timer >= 1332 then self.state = "IDLE" self.ammo = 6 if g_player then g_player.ammo = self.ammo end end if not enemy then return end -- Guard for input/combat logic local is_magic = g_player and g_player.magic_orb_timer > 0 local speed = is_magic and 1.1 or self.speed if self.state ~= "RELOAD" then if btn(0) and self.y > 16 then self.y = self.y - speed end if btn(1) and self.y < 119 then self.y = self.y + speed end if btn(2) and self.x > 40 then self.x = self.x - speed end if btn(3) and self.x < 199 then self.x = self.x + speed end end if btnp(5) and self.state ~= "RELOAD" and self.stock > 0 and self.ammo < 6 and not is_magic then self.state = "RELOAD" self.timer = 0 -- Only consume stock at the start self.stock = self.stock - 1 if g_player then g_player.stock = self.stock end end if btnp(4) and self.cooldown <= 0 and self.state ~= "RELOAD" then self.cooldown = is_magic and 500 or 800 --shoot interval self.timer = 0 if is_magic or self.ammo > 0 then self.state = "SHOOT" if not is_magic then self.ammo = self.ammo - 1 if g_player then g_player.ammo = self.ammo end end local sx, sy = 128, 136 local dx = self.x - sx local dy = self.y - sy table.insert(self.projectiles, { x = sx, y = sy, tx = self.x, ty = self.y, start_dist = math.max(1, math.sqrt(dx*dx + dy*dy)) }) else self.state = "SHOOT_EMPTY" end end for i = #self.projectiles, 1, -1 do local p = self.projectiles[i] local dx = p.tx - p.x local dy = p.ty - p.y local dist = math.sqrt(dx * dx + dy * dy) local step = is_magic and (3.0 * (delta / 16.66)) or (3.0 * (delta / 16.66)) if dist <= step then p.x = p.tx p.y = p.ty -- BUG-04: delegate armor check to enemy's own can_be_hit() -- BUG-05: delegate hitbox check to enemy's own hit_test() if enemy:can_be_hit() and enemy:hit_test(p.tx, p.ty) then enemy:take_hit() self.hit_timer = 250 self.hit_x, self.hit_y = p.tx, p.ty make_blood_ps(p.tx, p.ty) make_sparks_ps(p.tx, p.ty) end table.remove(self.projectiles, i) else p.x = p.x + (dx / dist) * step p.y = p.y + (dy / dist) * step end end end function PlayerCombat:draw(hide_crosshair) if not hide_crosshair then local sp = 236 -- Idle crosshair (Bank 0 Tiles) if self.state == "SHOOT" then -- Shoot with ammo: #204 for 10 frames (166ms) if self.timer < 166 then sp = 204 end elseif self.state == "SHOOT_EMPTY" then -- Shoot out of ammo: #238 for 10 frames (166ms) if self.timer < 166 then sp = 238 end elseif self.state == "RELOAD" then local frames = {148, 150, 180, 182} local idx = (math.floor(self.timer / 166) % 4) + 1 sp = frames[idx] end spr(sp, self.x - 8, self.y - 8, 0, 1, 0, 0, 2, 2) end if self.hit_timer > 0 then -- Hit marker: #206 for 15 frames (250ms) spr(206, self.hit_x - 8, self.hit_y - 8, 0, 1, 0, 0, 2, 2) end local is_magic = g_player and g_player.magic_orb_timer > 0 for i = 1, #self.projectiles do local p = self.projectiles[i] local current_dist = math.sqrt((p.tx - p.x)^2 + (p.ty - p.y)^2) local ratio = current_dist / p.start_dist local scale = math.floor(1 + 3 * ratio) if is_magic then local offset = scale * 8 spr(172, p.x - offset, p.y - offset, 0, scale, 0, 0, 2, 2) else local offset = scale * 4 spr(251, p.x - offset, p.y - offset, 0, scale, 0, 0, 1, 1) end end end State_Combat = {} function State_Combat:enter(params) self.enemy_type = params.enemy_type self.map_x = params.map_x self.map_y = params.map_y if self.enemy_type == "IMP" then sync(2, 2, false) self.enemy = Imp.new(120, 68) elseif self.enemy_type == "STRIKER" then sync(2, 3, false) -- Bank 3 for Striker local random_x = math.random(60, 123) self.enemy = Striker.new(random_x, 136) -- base_y is 136 (bottom screen) else -- Slime APPEAR state uses Bank 1 sprites sync(2, 1, false) self.enemy = Slime.new(120, 127) end self.player = PlayerCombat.new() end function State_Combat:exit() -- Safety net: if we exit combat while reloading, ensure the ammo is refilled -- since the stock was already consumed at the start of the reload. if self.player and self.player.state == "RELOAD" then g_player.ammo = 6 end end function State_Combat:process(delta) if g_player and g_player.magic_orb_timer > 0 then g_player.magic_orb_timer = math.max(0, g_player.magic_orb_timer - delta) end if self.enemy then self.enemy:process(delta) end -- BUG-06: removed duplicate hit_timer decrement; PlayerCombat:process handles it if self.enemy.state ~= "APPEAR" and self.enemy.state ~= "DIE" and self.enemy.state ~= "ESCAPE" then self.player:process(delta, self.enemy) else -- Enemy is in APPEAR/DIE: player input is frozen, but we still update -- internal timers and reload state so animations finish naturally. self.player:process(delta, nil) end end function State_Combat:draw(delta) -- No per-frame sync needed now! State_Explore:draw(delta) if self.enemy then self.enemy:draw() end self.player:draw(self.enemy.state == "DIE") end function draw_UI() if Game.state == State_Title or Game.state == State_Death or Game.state == State_GameOver then return end -- Fill UI side panels with color 1 rect(0, 0, 24, 136, 1) rect(216, 0, 24, 136, 1) local hp = g_player and g_player.hp or 10 local ammo = g_player and g_player.ammo or 6 local stock = g_player and g_player.stock or 2 local keys = g_player and g_player.keys or 0 if Game.state == State_Combat and Game.state.player then ammo = Game.state.player.ammo stock = Game.state.player.stock end -- Left Side (0 to 23) print("shot", 0, 2, 11) for i = 1, ammo do spr(240, 1, 10 + (i-1)*10, 0, 1, 0, 0, 3, 1) end rect(2, 75, 20, 13, 4) print(string.format("%02d", stock), 6, 79, 9) print("key", 3, 96, 11) if keys > 0 then spr(243, 7, 110, 0, 1, 0, 3, 2, 1) end -- Right Side (216 to 239) print("Life", 218, 2, 11) for i = 1, hp do local col = (i - 1) % 2 local row = math.floor((i - 1) / 2) -- 11x11 actual pixels inside 16x16 sprite. local x = 215 + col * 12 local y = 10 + row * 12 spr(208, x, y, 0, 1, 0, 0, 2, 2) end print("orb", 219, 96, 11) -- Centered message (e.g. "You need a key!" / "You Win!") if g_player and g_player.msg and g_player.msg_timer > 0 then local msg = g_player.msg -- Calculate width for centering (small font) local w = print(msg, 0, -20, 0, false, 1, true) local mx = (240 - w) // 2 -- Draw background rectangle with padding rect(mx - 4, 58, w + 8, 11, 1) print(msg, mx, 61, 12, false, 1, true) end local has_orb = g_player and g_player.magic_orb_timer > 0 if Game.state == State_Combat and ammo == 0 and not has_orb and Game.state.enemy and Game.state.enemy.state ~= "DIE" then if math.floor(time() / 500) % 2 == 0 then local prompt = stock > 0 and "PRESS X TO RELOAD" or "JUST PRAY..." local w = print(prompt, 0, -20) local px = (240 - w) // 2 print(prompt, px, 115, 12) end end if g_player and g_player.magic_orb_timer > 0 then spr(210, 220, 110, 0, 1, 0, 0, 2, 2) end end -- Screen effect state -- effect_type: "shake" = whole-screen uniform offset per frame -- "glitch" = per-scanline X offset (BDR) + frame-level Y jitter g_screen_effect = { effect_type = nil, timer = 0, intensity = 0 } -- Trigger a screen effect. -- effect_type: "shake" or "glitch" -- duration_ms: how long the effect lasts in milliseconds -- intensity: max pixel offset (e.g. 4) function trigger_effect(effect_type, duration_ms, intensity) g_screen_effect.effect_type = effect_type g_screen_effect.timer = duration_ms g_screen_effect.intensity = intensity end function TIC() poke(0x3FFB, 0) -- Disable mouse cursor local t = time() local delta = t - g_prev_time g_prev_time = t -- Update screen effect timer local fx = g_screen_effect if fx.timer > 0 then fx.timer = fx.timer - delta local d = fx.intensity if fx.timer <= 0 then fx.timer = 0 memset(0x3FF9, 0, 2) -- Clear X and Y screen offset registers elseif fx.effect_type == "shake" then -- Whole-screen uniform offset: both X and Y shift each frame poke(0x3FF9, math.random(-d, d)) poke(0x3FFA, math.random(-d, d)) elseif fx.effect_type == "glitch" then -- Frame-level Y jitter only; per-scanline X is handled in BDR() poke(0x3FFA, math.random(-d, d)) end end if not Game.state then if Debug_level and Debug_level > 0 then Game:change_state(State_Explore) else Game:change_state(State_Title) end end if g_player and g_player.hp <= 0 and Game.state ~= State_Death and Game.state ~= State_GameOver then Game:change_state(State_Death, {prev_state = Game.state}) end Game.state:process(delta) update_psystems() Game.state:draw(delta) draw_psystems() draw_UI() end function BDR(row) -- Per-scanline X offset: only active during "glitch" effect local fx = g_screen_effect if fx.timer > 0 and fx.effect_type == "glitch" then poke(0x3FF9, math.random(-fx.intensity, fx.intensity)) end end --------------------------------------------------------- -- PARTICLE SYSTEM (Minimalist Port) --------------------------------------------------------- particle_systems = {} function make_psystem(minlife, maxlife, minstartsize, maxstartsize, minendsize, maxendsize) local ps = { autoremove = true, minlife = minlife, maxlife = maxlife, minstartsize = minstartsize, maxstartsize = maxstartsize, minendsize = minendsize, maxendsize = maxendsize, particles = {}, emittimers = {}, emitters = {}, drawfuncs = {}, affectors = {} } table.insert(particle_systems, ps) return ps end function update_psystems() local timenow = time() for key,ps in pairs(particle_systems) do update_ps(ps, timenow) end end function update_ps(ps, timenow) -- BUG-02: use reverse-ipairs so table.remove doesn't corrupt the iterator for i = #ps.emittimers, 1, -1 do local et = ps.emittimers[i] local keep = et.timerfunc(ps, et.params) if keep == false then table.remove(ps.emittimers, i) end end for i = #ps.particles, 1, -1 do local p = ps.particles[i] p.phase = (timenow-p.starttime)/(p.deathtime-p.starttime) for _,a in ipairs(ps.affectors) do a.affectfunc(p, a.params) end p.x = p.x + p.vx p.y = p.y + p.vy local dead = false if (p.x<0 or p.x>240 or p.y<0 or p.y>136) then dead = true end if (timenow>=p.deathtime) then dead = true end if dead then table.remove(ps.particles, i) end end if ps.autoremove and #ps.particles <= 0 then for i = #particle_systems, 1, -1 do if particle_systems[i] == ps then table.remove(particle_systems, i) return end end end end function draw_psystems() for key,ps in pairs(particle_systems) do for key,df in pairs(ps.drawfuncs) do df.drawfunc(ps, df.params) end end end function emit_particle(psystem) local p = {} local e = psystem.emitters[math.random(#psystem.emitters)] e.emitfunc(p, e.params) p.phase = 0 p.starttime = time() p.deathtime = time()+frnd(psystem.maxlife-psystem.minlife)+psystem.minlife p.startsize = frnd(psystem.maxstartsize-psystem.minstartsize)+psystem.minstartsize p.endsize = frnd(psystem.maxendsize-psystem.minendsize)+psystem.minendsize table.insert(psystem.particles, p) end function frnd(max) return math.random()*max end function emittimer_burst(ps, params) for i=1,params.num do emit_particle(ps) end return false end function emitter_point(p, params) p.x = params.x p.y = params.y p.vx = frnd(params.maxstartvx-params.minstartvx)+params.minstartvx p.vy = frnd(params.maxstartvy-params.minstartvy)+params.minstartvy end function affect_force(p, params) p.vx = p.vx + params.fx p.vy = p.vy + params.fy end function affect_stopzone(p, params) if (p.x>=params.zoneminx and p.x<=params.zonemaxx and p.y>=params.zoneminy and p.y<=params.zonemaxy) then p.vx = 0 p.vy = 0 end end function draw_ps_fillcirc(ps, params) for _,p in ipairs(ps.particles) do -- BUG-03: clamp index so phase==1.0 doesn't go out of bounds local c = math.min(math.floor(p.phase*#params.colors)+1, #params.colors) local r = (1-p.phase)*p.startsize+p.phase*p.endsize circ(p.x,p.y,r,params.colors[c]) end end function draw_ps_pixel(ps, params) for _,p in ipairs(ps.particles) do -- BUG-03: clamp index so phase==1.0 doesn't go out of bounds local c = math.min(math.floor(p.phase*#params.colors)+1, #params.colors) pix(p.x,p.y,params.colors[c]) end end function make_blood_ps(ex,ey) local ps = make_psystem(2000,3000, 1,2,0.5,0.5) table.insert(ps.emittimers, { timerfunc = emittimer_burst, params = { num = 30} }) table.insert(ps.emitters, { emitfunc = emitter_point, params = { x = ex, y = ey, minstartvx = -2, maxstartvx = 2, minstartvy = -3, maxstartvy=-1 } }) table.insert(ps.drawfuncs, { drawfunc = draw_ps_pixel, params = { colors = {13} } }) table.insert(ps.affectors, { affectfunc = affect_force, params = { fx = 0, fy = 0.15 } }) table.insert(ps.affectors, { affectfunc = affect_stopzone, params = { zoneminx = 0, zonemaxx = 240, zoneminy = 120, zonemaxy = 136 } }) end function make_sparks_ps(ex,ey) local ps = make_psystem(300,700, 1,2, 0.5,0.5) table.insert(ps.emittimers, { timerfunc = emittimer_burst, params = { num = 10 } }) table.insert(ps.emitters, { emitfunc = emitter_point, params = { x = ex, y = ey, minstartvx = -1.5, maxstartvx = 1.5, minstartvy = -3, maxstartvy=-2 } }) table.insert(ps.drawfuncs, { drawfunc = draw_ps_fillcirc, params = { colors = {15,14,12,9,4,3} } }) table.insert(ps.affectors, { affectfunc = affect_force, params = { fx = 0, fy = 0.3 } }) end