From 915c684dc297f307e36b51a79015e455ef8afffe Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 24 Oct 2026 11:29:30 +0200 Subject: [PATCH] M8: progression, items, bankarta och sparfil MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - internal/progress: katalog (skills.json 12 noder, items.json 25 items) med validering; XP/nivåer (150·n(n+1)/2) och skillpoäng; skillträd med krav och kostnad; items per plats med buffar, genvägar (JR Pass, kartor, powerbank) och färgton; banresultat och upplåsning (huvudbanor i ordning, sidospår efter föräldern); 33 %-regeln som test - internal/save: versionerad JSON med kunskap, plånbok, debuffar, progression och pågående bana + seed; atomisk skrivning; migrering; ~/.config/metro-puzzle-jp/save.json resp. %AppData% - sim: RunOptions får Extra/FogRadius/Landmarks/FreeGates/RevealAll/ InfiniteBattery; luck påverkar trängselzonen; Fog.RevealAll och extra landmärken - game: bankarta i spelbrädesstil ersätter menyn (⟳ återupptar med sparad seed), skillträd (T), garderob (I), butik i kiosken (B), XP/nivå/items/ upplåsningar på resultatet, spelaren tonas av skins; autospar vid banstart, efter banan och vid köp; flaggor -save/-nosave - level: kind/order/parent/rewards/shop; ny sidobana 002b Kabukicho som belönar skinnet Kimono; 001 belönar Salaryman-skinnet; butikssortiment på 002/003 - tools/shots: demo-sparfil (build/, aldrig den riktiga), bilder av karta, skills, garderob och butik; alla körningar med -nosave - docs/README/wiki uppdaterade Co-Authored-By: Claude Fable 5.1 --- README.md | 38 +- cmd/metro-puzzle-jp/main.go | 4 +- doc/img/items.png | Bin 0 -> 17032 bytes doc/img/map.png | Bin 0 -> 17032 bytes doc/img/menu.png | Bin 15378 -> 0 bytes doc/img/result.png | Bin 26513 -> 26637 bytes doc/img/ride.png | Bin 21363 -> 20907 bytes doc/img/shop.png | Bin 0 -> 21671 bytes doc/img/skills.png | Bin 0 -> 17032 bytes doc/img/ticket.png | Bin 21515 -> 21734 bytes doc/img/words.png | Bin 16161 -> 17032 bytes doc/mal-och-tester.md | 10 +- doc/plan.md | 6 +- doc/roadmap.md | 46 ++- internal/game/game.go | 240 ++++++++++-- internal/game/input.go | 9 + internal/game/scene_items.go | 144 +++++++ internal/game/scene_level.go | 43 +- internal/game/scene_map.go | 227 +++++++++++ internal/game/scene_menu.go | 112 ------ internal/game/scene_result.go | 49 ++- internal/game/scene_skills.go | 113 ++++++ internal/game/scene_words.go | 1 + internal/game/shop.go | 104 +++++ internal/level/level.go | 47 +++ internal/level/level_test.go | 46 +++ internal/minigame/crowd.go | 10 +- internal/minigame/minigame_test.go | 19 +- internal/progress/catalog.go | 168 ++++++++ internal/progress/progress.go | 262 +++++++++++++ internal/progress/progress_test.go | 216 +++++++++++ internal/save/save.go | 122 ++++++ internal/save/save_test.go | 78 ++++ internal/sim/fog.go | 29 +- internal/sim/run.go | 36 +- internal/sim/run_test.go | 47 +++ resources/data/items.json | 432 +++++++++++++++++++++ resources/data/levels/001-flygplatsen.json | 7 + resources/data/levels/002-shinjuku.json | 95 +++-- resources/data/levels/002b-kabukicho.json | 122 ++++++ resources/data/levels/003-shibuya.json | 98 ++--- resources/data/skills.json | 239 ++++++++++++ resources/text/en.json | 53 ++- resources/text/jp-dictionary.json | 8 + resources/text/sv.json | 53 ++- tools/shots/main.go | 41 +- 46 files changed, 3058 insertions(+), 316 deletions(-) create mode 100644 doc/img/items.png create mode 100644 doc/img/map.png delete mode 100644 doc/img/menu.png create mode 100644 doc/img/shop.png create mode 100644 doc/img/skills.png create mode 100644 internal/game/scene_items.go create mode 100644 internal/game/scene_map.go delete mode 100644 internal/game/scene_menu.go create mode 100644 internal/game/scene_skills.go create mode 100644 internal/game/shop.go create mode 100644 internal/progress/catalog.go create mode 100644 internal/progress/progress.go create mode 100644 internal/progress/progress_test.go create mode 100644 internal/save/save.go create mode 100644 internal/save/save_test.go create mode 100644 resources/data/items.json create mode 100644 resources/data/levels/002b-kabukicho.json create mode 100644 resources/data/skills.json diff --git a/README.md b/README.md index 5bce7bc..e2f3c1b 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,16 @@ Storyn följer några polare på semester i Japan, från landningen på flygplatsen till avresan. Efter storyn låses ett oändligt läge och New Game+ upp. Hela speldesignen finns i [doc/spelbeskrivning.md](doc/spelbeskrivning.md). -**Status:** M7b klar (2026-10-24) – tre banor med stigande svårighet. -Japanska skyltar och ordinlärning, lämmeltåget med sina behov, minikarta med -dimma, tågkort, biljetter och minispel, svårighet med tidsgräns, poäng, -debuffar, egna kort och sittplatsval. Och nu lever tågen: de rullar in på -rälsen till avgångsmelodin, står med öppna dörrar, dörrsignalen ljuder och -de accelererar ut igen, med vind längs perrongen och lampor som varnar den -som står nära rälsen. Nästa är M8: progression, items och sparfiler. Se -[doc/roadmap.md](doc/roadmap.md). +**Status:** M8 klar (2026-10-24) – fyra banor på en bankarta i +spelbrädesstil, med sidospår. Japanska skyltar och ordinlärning, +lämmeltåget, minikarta, tågkort, biljetter och minispel, svårighet med +tidsgräns, poäng och debuffar, tåg som rullar in till avgångsmelodin. Och nu +progression: XP och nivåer, ett skillträd, items med buffar och genvägar som +köps i kioskernas butiker eller vinns på sidospår, och en sparfil som minns +allt inklusive seeden på den bana du höll på med. Nästa är M9a: stationer i +flera våningar. Se [doc/roadmap.md](doc/roadmap.md). + +![Bankartan](doc/img/map.png) ![Skylt på japanska med delvis översättning](doc/img/sign.png) @@ -42,6 +44,13 @@ på japanska. ![Automaten](doc/img/ticket.png) +I kioskens butik (B) köper du hattar, masker, kläder, skor och prylar med +buffar; några är genvägar, som JR Pass som öppnar alla spärrar. Klarade +banor ger XP, nivåer ger skillpoäng till skillträdet (T), och garderoben (I) +visar vad du bär. + +![Skillträdet](doc/img/skills.png) + På de svåraste banorna har alla i sällskapet egna kort som måste ha saldo, alla behöver biljett, och när biljetten är köpt ska sällskapet placeras i vagnen efter sina önskemål: fönster eller gång, vänster eller höger sida, @@ -122,7 +131,9 @@ build/ Spelet letar efter `resources/` bredvid binären, annars uppåt från arbetskatalogen (så `go run` och F5 fungerar), och `JP_RESOURCES=/sökväg` -överstyr allt. Att distribuera spelet = zippa en plattformsmapp. +överstyr allt. Att distribuera spelet = zippa en plattformsmapp. Sparfilen +ligger utanför spelmappen, i `~/.config/metro-puzzle-jp/` (Linux) eller +`%AppData%\metro-puzzle-jp\` (Windows). ### Flaggor @@ -136,6 +147,8 @@ metro-puzzle-jp -seed 7 # fast seed för händelser, läsning metro-puzzle-jp -phone -card 0 # kortet i telefonen; kortets startsaldo (tester/skärmdumpar) metro-puzzle-jp -debuffs sore_feet,tired # debuffar från start (tester/skärmdumpar) metro-puzzle-jp -mute # inget ljud +metro-puzzle-jp -save /sökväg/save.json # annan sparfil (standard ~/.config/metro-puzzle-jp/save.json) +metro-puzzle-jp -nosave # läs sparfilen men skriv aldrig (tester/skärmdumpar) metro-puzzle-jp -script "move right 4; shot bild.png; quit" # skriptläge, se internal/game/script.go metro-puzzle-jp -version ``` @@ -145,8 +158,9 @@ metro-puzzle-jp -version Pilar eller WASD går. Enter väljer, kliver på tåget, använder toaletten, handlar i kiosken eller startar automaten; i minispelen väljer pilarna och Enter bekräftar (håll Enter för att mata in sedlar). H frågar om hjälp vid en -skylt, L öppnar ordlistan, M döljer/visar minikartan, P i menyn lägger -kortet i telefonen, Esc går tillbaka, R spelar om. Gamepad +skylt, L öppnar ordlistan, T skillträdet, I garderoben, B butiken vid en kiosk, M +döljer/visar minikartan, P på kartan lägger kortet i telefonen, Esc går +tillbaka, R spelar om. Gamepad med standardlayout fungerar: d-pad/vänster spak, A = välj, B = tillbaka, Y = hjälp, X = ordlista, Back/Select = karta. @@ -187,6 +201,8 @@ internal/game/ Ebitengine: scener, input, rendering, skriptläge (enda internal/sim/ rutbaserad rörelse, kollision, kamera, banomgång med tåg/spärrar/hjälp (ren Go) internal/lang/ japanska: ordbok, kunskap, delvis översättning (ren Go) internal/minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade minispel (ren Go) +internal/progress/ XP, skillträd, items, banresultat, upplåsning (ren Go) +internal/save/ sparfil (versionerad JSON) internal/level/ banformat, validering, framkomlighet internal/res/ hittar och läser resources/ internal/rng/ seedad slump diff --git a/cmd/metro-puzzle-jp/main.go b/cmd/metro-puzzle-jp/main.go index fdabec7..96e2645 100644 --- a/cmd/metro-puzzle-jp/main.go +++ b/cmd/metro-puzzle-jp/main.go @@ -26,6 +26,8 @@ func main() { card := flag.Int("card", -1, "kortets startsaldo i yen, -1 = standard (tester/skärmdumpar)") debuffs := flag.String("debuffs", "", "debuffar från start, kommaseparerade, t.ex. sore_feet,tired (tester/skärmdumpar)") mute := flag.Bool("mute", false, "inget ljud") + savePath := flag.String("save", "", "sparfil (standard: ~/.config/metro-puzzle-jp/save.json resp. %AppData%)") + noSave := flag.Bool("nosave", false, "läs sparfilen men skriv aldrig (tester/skärmdumpar)") version := flag.Bool("version", false, "skriv ut version och avsluta") flag.Parse() @@ -34,7 +36,7 @@ func main() { return } - g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute}) + g, err := game.New(game.Options{LevelID: *levelID, SmokeFrames: *smoke, Script: *script, KnownWords: splitWords(*know), Points: *points, Seed: *seed, Phone: *phone, CardYen: *card, Debuffs: splitWords(*debuffs), Mute: *mute, SavePath: *savePath, NoSave: *noSave}) if err != nil { log.Fatalf("kunde inte starta: %v", err) } diff --git a/doc/img/items.png b/doc/img/items.png new file mode 100644 index 0000000000000000000000000000000000000000..37e4466c4a6c6855d823936e431b4fd9e3082a60 GIT binary patch literal 17032 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 C5CH!G literal 0 HcmV?d00001 diff --git a/doc/img/map.png b/doc/img/map.png new file mode 100644 index 0000000000000000000000000000000000000000..37e4466c4a6c6855d823936e431b4fd9e3082a60 GIT binary patch literal 17032 zcmd^n2_V#6*SJtZp%6)yBoRr(5QbE;rIIyE*+q8QXOb=JBxDJrBxOy=HYj3}Wk$-_ zCk@6v7>t=QGk-nH`&19_+e=w{lb165$Xlv2~!YUGS?R9D{2q(XwyYb+5c4cLA%5;onLBP-UX9v`V)%bu;Y)N=Py z#9Il&thiZ0;il^`+iC^d`YbtEnf9?s@|yr&x?dl8(-;!Y@J|~6$i~tIHYOSOJLO`> z#5g5H124>H_{@K(569Y)OjEiItCMCF)fd;idCq>(n76g!9`*CQ)aNCC=32S2D^62b zW^*I6B2EP`Cza>~sWr;WBhaxE-0?99H-At4H}oitJAWO=}_ zqm2>;x|db9$HGVckY9<<<133w0#{DFZ?Zz92=eep#0ZuXGjx)@S~W%V@vH6h6EVLXic1J{zRbQN2g z{KegM*T-Y@7Efubr1V<4KVTRvW^f&|l{hA5GN#X;mO(6?lR8UQjQ9FoR6=RSPAQ6uK2JjVX;*r(>}CEGqd zOOwBgyznp}Vqh|_E@1EBn0g5Vu|&>7J}n=BCqY9#e87JU$o~`dCD%>q{%T$eU{+1{ z4-K7{@Ao|!Rhs8lS+IqJT16SRS2%Gjj)?G_t;UO zEje2}nk^F!^L#1MM3KNna#qx!Yau~)v@dXw|0d?M$rP{=WQ8ZDAkdNxZ-HM_8Q1+$ z6Gz5?ebt}hfdW=nCvH?ql*8};L zfvejYNS5^y6L~78in*6gE4NSG+4L+=nf7B`{NW z>`Q?F`7LB9cy!v&@qw+w5eK>>PC1!rDCXfIjp|YKOh#QampWIt={$SbZmzfQH0POj z=!UfhF))a5a>{%>yH7+!Mn>l3{O(x2AfeFh>OE?Slbr20iYxOANDkw*3Qqa1hM7~t z;a{g^EevK7UZe>f#sE%M6{s5z?S1EIJsQbp5}aG0ku8kmaNH@!$Vb0BBtq>MbfSfE zQy(PBpT=XTuJ=r5VGI*F;>m-stnZ(`qH`kusO(8)^WOJq2;A_2$q)Anr;G>!WOw^cVr{2FV8_^>K)deqy4n*BQXMg( zy6K;vq>FdkU4Tt51Un_=@i65?N5@>Lofi!XE!Hd`D^IP{6IRsA3Z}`MuS7RMpu18E z>ytkJc_5BEy=^wv#J+ftxPj)pkm@s7bHAM{@`sF?X#!dAmy#{TeIQZeTebqw0*mwLNPs8N}X3`ZS9Xs6zf=>k@D%& zm-t?qKMFTS2)W5xIblC?zw1mxnUPL`plhHxWtf(_f+aA;(9CC}OVCw{K-aI5U-#$*;1Oc=s((r1= z=CY^56O#v_zIG9TK>@y{ z*s=Gq`H3<*u`nO}@exi^`?&&r)0tB`tf(0ak(@c&v3rL)Vx)|8@K%`$`si$2qcd0j z!bsZJCp(uIPr_I?V&m6&lqiDa&sJ3Px-(AKu`QiJj>(bjw}#^YHK$ z2kT|En)EU75%;>r5pK2;cfataWBisQ^DL3Z09d0;U86^hIV(wl&vAG84l{{H0i&`#IvZG1nLfNj|b3v`R`9)AjTK~dkpI8tVk7=`xy8h?7O3iI2e&sP_)b?8ZR zr;}4-`2Vvk&D~K|{iskM^L~~ZufJ#gl`t#&(u?-|V%TNg7N+v~ySGsnSoVBm-Y0Q4 z*~rYaX8WE3ljq^#j0IPzL*dRZKece5<reE;IOr>{e#w^aYB~$#c_GT z6gA5%Wll!Ekn`Dl9KWv=G_$!Kx0{6+{Y{mD1X9z+N&C1(-9Bd#&ylayCOlq?PSQQQ z9lXivh3B3nYJoaBy_GylbX9jpnr1Z_9h*yz!UG`Riev_Mx)SP9G6}UhO{Dn&^Oixk zv&OKG-7f)-@^i7VFolOUs*hxEirVONvDw7RJo_579~`?}q6oSq*`|H)h zL*!&~rR%$zWn~am!svS${3elZG2})>MC`(CmTmwD0Uf&Vg(SP$kKO47%FW8DlzTo+ zim70ySQ-1GN#InA8Cs!@IT*Cxz`Ea9prvMcu-aesUn3*(n13Z}3^lVvsPrhgGA!ss z;@NlTP}KO$kZ);m!rPd((&!5b3y(mB%-0U>l%tPR6G47op)|9_HxyIOzHpn?!9b!? zgS~zY3Nze5JACL+#hAWIPR6+KD>tnRCvhi`a25aO#HYt8Hz+W{uJCF4CP4l-5 z-&6Fl2lkwAU&dfe@c5|@%_hgP9to;8-(0t{`CUwPCdL65K74I53ZUF5B>KzpHTN%} zrERp^@e3#ul&WuhXLIyVN?KQ$H<`J76F&L}8@m((Ih;ZE=#RNL#a8hp#Y|U)9+Evx z2wbX_ZsZB=^Cp#mpCGFsZMtr~Oke6=ZT?XU&)(Z?3FG#UzQ0A2$@iNONH$K9K{ zV)9Duy>Qe}lY(l5Jq^62Y4mN9_3jOC^5BPi$Oje+43UTr4&z#+F>c>m+mE)Fa%jW3 z6WVw=rqSa@KW#ZZXpZfx+vlL`ugHLF>m6tGv#;CE+rjIuYo7-gRm z)#;$KXj>1fe+1TDi*q1GR?~1Qk$p@@aAn3D%6gTI#?&{9F8HF+z`8$>We6NIE_>21mNx+1upq z{DMetL!v>SsqbpIh<%PNuecOvmaWAP9eQ=5>8;Si6=|~ZXF^E~zRcuV-vG{WgAimQ zS|0h#J+<)$OQbmePjBGz`Xj4gWSpJ&1N_`f?d5sc%=y-oBv-OcyrSlKl@*cl3P_};CL%mQ8G=|W3@`FeI{kqpT#=dS ze`nz9eN7GATWgcFCd^p+>UBUkN5q$buF5$D zrxqcZOJU38t7=G1F7OBo}Tmp~|XV7rx+WgCiZ z59BXQ=MOCh?~RWsSdbqVZCP67>fVc?jvRmX;xrG&l?RbK_Y?*WYVj&YL`{2@q2XS` zh$w;$r5yDeB|My%s0c2E`VkaU=zZa4B@D-`UVz1%wttbGAK6ddMrkfSm?Mv1 zaOZb@>?MXVN>oP{crV>hSve7LaXUtXi{9+arHl(~js8i^P4Gx7R79j8_;1|U{H?pB z-#eRtb4{QSbH^+cJcfZ0-hJN9~RO@MfWNMhn7}}>7ivbPlw^G)T+WBCWNdmWZazkJd0Up-0Ww3$%-{sxBF?XmA zIO=6H)+cmqkS}}BAdOy@b&1-t7r(J%z;3JS?ZZ+3%<=j*8pwq z@_JxZa#`;K0pS*851xTgs^=*l0RHKN!@yx+8e!|6b^5>H>ViGhQpo2d1aWN*Ktb|! zn*xD-p-&vFjiUAnl5N28!_d(iDm^wTnah4OKbq|@aLXbrOx!4mD|Ln+EqHf{ zU-Ll93C6;(8L-}ZtW>qL;9As^!y%_fgFbUXZdgXoV^+nGjOl8ej`Vuj%yJ<|{#PQF zQpgm)_lty}azEXLtiVT0Ds^z1Xj%&1L$yKb%pi*D>(Nc}ebP*y|VI1Xq>2J%|gw4F=u5uA}6b%zH>M_cD8KX$} z+A&(&J-QnL^G`XY%Blz`5JGOFBv&_>1K~BGEo~#dQGA~0&YQg(xxIt@+lSUy*iv_;U zaBoFuQp97SU2i^D-R91>;HI~1wsRXfIOxY{ETC>)HG7er(_J)aI9rcHo2gu=zy54i zheFx@tBp48p^*6)_j{Yd63+k5?cMrV{7%(W$)0%(1|T!W0teMb$xNTh9WKZhXg7K-sNc0@b$d=IH(BUj)2hu8&7C1{7=%yje;FC{ zW5SIx4>GF{F`kLP&h9FyF5DD$(N z2AzWGQAkxiC9q{p@RGw*VCz@_UxNSwI;H~F7B4L_5Mk=n8@Vt{=Af})Se(}}y|89F zjCVLH`WijGydeCVb`nQ4&xJM18#bMICvPZ1po(G32chCn!!0Lb`7Q%`1}t0<7e8qZ z%i39ZcLtP2-I{t-QZQ8fLBWHR1Js#D7oq@+4kyNDX7@~kCggmstjnQqjVNia>_Z+de9-H!LA&QC7kQw zWI)*9+YcZ6KV0hz+~Kz8<=!(K$By}y2UsM9C@^cR$d>p^zUpKvYnyLP`Cq>4`1kfo z2>onq*V4%qw*f~3aQ75g@5m#^8s1Z0#ev0IIb;*T=|odOf(NucI0)Q1OE!oQ?*HWI z12u?GSF8*uHXneoP*DotNttf!lT^R$PKXA`k2z{I>_r7k6||V>h``R~p_~ z6rwyjg4o)z6pUlrrVDw38MO zqH1{SiV!aHz`me4f%SmM2bh<&KHHP7Xul{;W+?v-meK^*gU#B|VY+r_K9|!gu zJRiThb5-JQ%CHJCUWFua2AOze%1|;@i0k}I$8>LNy(ZkFCeXE9-8}Rg2=EjAf9V(Drl&|p%Ks5{eII^682@y_ zbDECi<`{9s)H3XLWe;R*80}Q1q@pkXD17CeC_F9`VlC{Ld?v{A*lf^UhV{b=t=zcE zUR%BxTW_Aej$j)+&&c5D+!vcwQLP7uC$G}~zp}&m_YF;e8OgIJAZ=)XzjR8rh>DUM z(mcVSJDxQ@NB%US6s!VFlf~Af*53|G<4{DH)%dFIoRjIpdxqUpN;JhZ*-DaSQLzof ztqmcwU#-CX`34YcJ~DCZ_Q0)o`Tm*Mwa82?0y{a2z{&RDFv@U5_kbVBh`MH8r>v>m zP9@2IofOYKTbOP~jP^6?9>C!X@aC#2=at*BLr1__?Xo2s1l}73+Z4psI52myRPPQ zmvqlO(GhqnR9$QSaSgXf99k?7U&M8ft}b?z=tXPXgg~k8yf!(;7Xx{C7OKyejP@f| zC{I^{z-yy7Vzp-Als*a(Nx-tZq2E>MM3>2~A0c=xyHwC1v4^iyld$iXMv~aLuo`mRAoPI$ax7x2?S4rv5Fmdxix8)OfaYqLXqp! zZvyDw#hM;Zk#V)>w)(ILt6>~m+#U1eo=lXB;?3k~Fj>+5%4vkPY6&Pyf}r$M4F7FR z&b*G?Y#TtlyfI*XJZH6K{p#*5xMjhG#FB9O3Se9)FYnKLf%K4zjX&O0*etC2=|aQ* z^v7!S=z?co5roDMdy-v&=M#FcpB=eu_WCObz#Z2U2P8^WjW13cCE!fR?Bw9 zd0!5{q5{sm<^7+w*it8v-^taHX1l z_KuP4o@A_1KH%q;qM8I|`J?gpd9#f^Pz$HEoSoje~50^0t>W zgVHs}Zwrt?#wxYP*K{`;eSqS2z7aJ52U{4!rd9ktX87IFmZ!&tFT&pXbu4d}`AIv! z>WY@9Ci1h%Fg2FiHkJmPMB05094A?+sl(wfQ!biOy#-_0X| zgDs^X*SWSY% z++p$5Y@L1JJA6;M#aW_tc)E_W9lGZ%X8iWtJUhobbz=`t4$gNEuy1Nt((%Vroh=b% z%9e-~&q1DH7+$O1K<==AgbR;0VAqaN-tJ^u#RnD_U)A;Mz?Df9D;fDKG?hTl6$Rtv znhl(neYRNV2JVZMZmB)1^KGiwlZzP*yqL3}Mnod5C6_y$99)mbCkKcT1_**4?UcSz zDt{^zVpHspxV{A*1p<3gmhFNm=;dWsieaox$?e*Mm=*NPgrdWtT`mtj6SN*Y{Vb83 zuSb8lIB#uP(z~R&Y7B2aW$08?v7T2{x%J0&uiw4naXMrf_gcJDQ3?WOU08JyTE~&I zo*6LAFSb(R#=NW-IK3b9a-}Yq)212g?N-ua(jZZ-z_M`?kOEiRIQ9HVv2lTj$!nle zN(NGVS00kQZ$H+Tp#@P6wOpx~9y}9!dWt&H>V(jz;xW`wwBU3biH|^5WNRZeKEe_2 zK0P1J$Sf7<9edlwt{ouDZxpcT%7VM9#UoB(cp{MNNnd2PYHaG4%$6B@Ttf)}Pot;k(-8%q< zXJ%LJHQ&xCe4DQo31TbJ z?HGSW!u-3m0}I9Dh0Yb`!$FVp1ysTQ%WlhaGv%KXS6_rlr!<)DI^WQLRHiMVyUeU8 zojJcJH6t}NoY@AE_OiQ<?Dw5MA-cQ#hvDNmLJ{$9i%-04lkeqBC7h;)h&jb;wrnGrE zt)el^aiFJX`SrzgW)Q0wt+k6g5%}|6?wyL*46k~(;~VXfPaj4yZ6CiLSLg3DEO)vW zlNSuq7UmAW%5~-Pg>&EDtibMn@DWa4CpJ-q62R;)4cEEh?%r$^A^w_vT`e04;18w! z+1!zN!L-L5A%(PWP^~z%FfZ!gvfzk6pFA|#pXD3=0nDQLX z^+thw*+ZMeIUkp^JhUm0{M`=z(dDmZu@M@-Ai@t7tU#Mk@GB+L3P#`tSK}!f$(AC6 zTfbb+d!X0TFwU~F-m^YmOy)YdgC)Q>?wagqu>e7H!kl7I~UZG&f;uk8bIjWq~%}<~5Dbgz91o z1uF0~v=wUuoTPkO0YL3CA(W>Cg8@v?N387H;)U@kvIDFip}itI-l?;-E20{Wmye9B zl3b}@>!Pvl%FWK)2v?e5i77hh)U5+hyI@Y=THse%Y}p-r>BlyT2Y3+bNq`fdBH)bu zn*d3J^WV=lOo(o)sS&(Vol#o})~CFnjAIp4AK^<1F`LD!&`ar;KwT2yZg0Btv#a?IXe*0e!TIVjFEk1Mo?*9SL C5CH!G literal 0 HcmV?d00001 diff --git a/doc/img/menu.png b/doc/img/menu.png deleted file mode 100644 index 7ec5b57e9b2af6e9db96a648c6b94a82c8ffc45c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15378 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 diff --git a/doc/img/result.png b/doc/img/result.png index 9bb8d95325f90d6f68a05ea63dd5635ce465b8c9..16a17cec9ab519ba92f13873126e85a2771e75ea 100644 GIT binary patch delta 5458 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 delta 5035 zcma)A2Uru?7Dhr7snSC4L{LiTO?pv~DqWhgKoF%#Zv#?Q5``d0F*HFC0TB=b0)kSc zB@{t{1dy&$Lg@A3uI_ug%YNUx|IFO~PC4hl_srb+?tx=ZBx6oI5ISK~DXeJ7Z(+6Q9b5+0+Wcl4Fx157?wg5#zQduYYjfRd8F@2P^%QpyQA&J z0Xy3W$hFzP)y!Nv<)d3zU4bGi8&S0bf3okCc{c(lIb^I z_HL=p8N{obm3LlEo75ZhQ|)zMifhlm9i_fiHDMy!9{Sp8_4SG>^)vYW%DctnZpv0S z!^H>?gQTm9IkNAV7KyS}7?weggPK{;2D~AS2S_z@4l?-=uUP zo?4a4d1Z7oPr?vQXzMjDzCu04yErqQ?~xgMOL)81N$DaxyzQh@0e`wd1{Z|=04#FQ zp=d^c3T*7J5lx3#)w09UFVSmHD6AGtN@kwF1>6Pwx$l0WZh47^C93l%aZM7$(iRza3VLG?hVtZS<3X(dE$2hk7u ziZOuaKyv72gM)U~wTyu7E#lfE_a-vnz--nvTnc*}9!g`8SM^ztZ>7nAWU)!?&fPD9 z_l)0NF1Q;0F5b#3IP@dM6s+R3!nB4|k7YsFh_DGK+9In^+?IDCQuTc3%Sz}=Xu7?a zYn%N;n=r%&`2`rbN?*5oz~Or1~0V!Em9B_%$PX<*_ zVBV{qGS$@9WF%DN6(&y5o(UmG@tGT(7UP?4iX%fMS80pjalUU}sW%N|2M@PbsU*)% z*E*R9oxYJDHkY5tG9!)=z;{7!{y<~=xNvWxc!Fm+=9b6ogpDrXY22FPG3HPAt3p-Ws z7}5tA`Nl4gzv9}n5DSu*c0nZcTim=@D_6>B$PgBJ|Zv?1I1f$Nq$a}EU4vNMh^ zJd@FH?HI>V__S)jI-MfrH|M5gyw1^4pbIB^ha}^gLZB8oqmOun#|71(#;E9#ljed+78%8UJV|_e*HO82|_G)^9}Xm zFwa-A2=C+#O7e}a3W#3Po2rNM)+!M5_G=<9q>Gi#b+KVuJmWs9?=be4f0G~HgdZ-& zuGvL!?!VzR|3k`Om7VvbL#{Bg0OfrelIkG`YSTt*GZW4W=}R}g6NWQkfVcN+`t2dB z;6=N|M*K++%b4bsd*x@(mdS(f)LX8FmL0S`+9hP?IN2*n>x6@ny;xGnCy@2a~p`uPiKO^NcEd6ZywTlShj!jERtUq1P;cNuR^Dh?rt3y(o+im;NgbwtPicr~>UepKzV zon{v}OFptr0b7Q1yEE$VE>ZEvG8nmJuSXDX1ouwXKAYS+Y>GLnK8GpcVNYIQ&yBizdlH-8u6?|y4UF{BN^qvh5 z`uy_E4Fl1_RP(P+52+H41<+2zh~QVSO|w7xZS?>7B)@_m|3mYmX9uN)TlkL!)FD=o z&^&u{biZLSGH1HVhIRi`gEROzKZw>f!Pr^tuqeT9v3XP3Y8^huCa^Cl> z%WmvGzz|dJf&EN`k_QOPHwD(QaSfK)YyjZ$o6-6{3CRP&g5FJhD{edT`Ll7yz(((# zaJET?e}mpXE<6%#A#4w%&sR+@c%Vm_>i5+~4wnv5Q=1?X8_4DJl~+p#@2{thx&|~h z9j-t}tkH*oOyWPL5}>yy`hR8r?8FcC(_1UoYom9(K_^H^p1gQ6Bz>Wkk0r)}#vJ

|%ALX+f-WK5Ig5i7dQ`NX@BoQSb>{vq1FT zDL>t7e(LgiZ>EiWm%Xw-KkmUTMK)G+xGeW9^>$M# zD9d6K=dxTUenRb>m`xh|9zNEvr|fJTANX!$er_LZP`Jnb%Bc87$aU99Gjn7zKeN_j1=3^8xa6Lv@vjDa&V+a| zx4Wmfm22+lo5wO}Q3+cx>lP#=vLhUuBt2$#>UNEOH{;--^RH&srgkxWk_za~%wQ?1 z)fDFROfQ8%AVEcL&{UiD=pgf|?M-V;hpxX#*dgw6dPGpk_TEC5uF;~Nk=mO<-#MG# zJ?K2X@~-$%kctQOS~VkkUrS zYak;8DFJ_vlfNSNmjG@^Vf*@5Z%?Dm>Kh;);KeuiM}s&*UX5sbr-00i3cYm#aCZ1x z4WUryE==}oN?Q2R;cR(M-9ovESx!h*uS8bLr2(8`#rc}&dS<*)qTl8(NT-4vW)4?} z0uB4`*kw4SGXD^PjOAk>or5DWVypl?hdmgUmg%l12ZL3o&?zwaK3#4?Zh~A`v=Z7A-!*K(4C8!NH-d^A1EV$fyM7r(}GSL*^vi zU58no+WRz?w$ko>)ms#)JkWhIhH5})gpK7C6Fun%w$({pLQUn^jLt)8rIBMeG_9$xujdr z?BT)Fse7%u=xkc*s=YvT2)l2vDTP8{O`}*kXO}aNS{PxxNp!+j8tl{sHD9-Wdx>j! zdsFd%jf}ZH;dYHxDR1pnRd*BZw9nR8dhNQPS7xhJx;MKl(QjC$e8ZPBYPhB-g_G#F zRcyJ7DrP7plHvjBFnTG2ZqSaM!MmxB*pnMe?(<-$!QoZKh-?0o5zAg9&MP2s%gpNb*kriGh(lyDdr(8R_QHZO;A7Uq=?`6Z=c^VL%C zFgTDq0-woT}cpvus^pLVgYtads;!Z&Y}; zlrFG)4g+Db1!I&Rp=`UL8HzU_(Ey4>RrfDs+K+d!p;|?mCbOu(xDSJo|Dx6<*JCm_ zE{2uPP7(H3f+%98dovUh6nAsUx6bZb*Meo(Fhk=hdSjlZz8L-LMVgoejusbhmF>J4 zF4dDa@gZ-;map~^iGVJ8>{EK{u;1s^y78U>o@b<9N)5y)?XLf>!2h-YPmV4+R>AL@ z<_qHrh{J)FhFj+-zad`z+l)Ni=QJg6cJ@D$0)LgnK@lnm zhEX4f`Mab$6L{S2OsH&N@av|>ko!)J2LmCivuhsPN12uz6{q2nZN3puzoxc;rqCDb zJMQRMq=r5VfD!<~sVT*{ihXihqW?kq?om@BrWDo4o-#RiJSMsRX(AH-C9+xL-LVM5 zXcQb{`RZBQwKF(j z3RtgPoJM_gAZU?dgtq#hC+6!pVf-xr2jqX<^pLhBd~c{)<4UeLmKAoYntX)xX7sCL zoAYC&$P?~Fltozx$N diff --git a/doc/img/ride.png b/doc/img/ride.png index 3d14fc31f03c27b1ec6ef0626dce593d932cc280..39e4f6ddc12f6a76c36b81dc8ed214b1bab1eb04 100644 GIT binary patch delta 9374 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 delta 9882 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 OP>{VZQ+Vg$i~k1zVj}ne literal 0 HcmV?d00001 diff --git a/doc/img/skills.png b/doc/img/skills.png new file mode 100644 index 0000000000000000000000000000000000000000..91be59d5f5acb4b9bab0f215dae90ccd9846deb4 GIT binary patch literal 17032 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

r1(Aqu4}7Z$~Jp9 zr`;+|)=k!=6|%W>ilX)8b)N5xKea%4G%%9$=0Nz|^LN62zuYCv z;pLQ+q&K#H;mEWPp%X9G^ChL3FG5^E4RdB&x8h8VTJ#6_`D5Su%+)PY`iVMfK$14D z;5m{gH4`)|)(HP3fK54N5;-PrRp@t< zMDgofn<%_Wz2-8LQ8Pf^4OfEi$b=Hpnn{_mc0u z86RvU2TtqFDT139`!k_!vq1_FwKSI%k_U7UNGKR2d#Fv}+k{q(%}xBvB^edfaHl9N|%{|_(AQJeq( literal 0 HcmV?d00001 diff --git a/doc/img/ticket.png b/doc/img/ticket.png index 435d60eb55afeb2262cb9120132511a7375a21c2..27231f4d1459eee099a4000aa53e9279c5399faf 100644 GIT binary patch delta 9948 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 IYW5HQADvQRCIA2c delta 9759 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 diff --git a/doc/img/words.png b/doc/img/words.png index 8332044fa8d69e58f54755e7804ba6a1a9aaab71..37e4466c4a6c6855d823936e431b4fd9e3082a60 100644 GIT binary patch literal 17032 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 C5CH!G literal 16161 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 E0BDoeC;$Ke diff --git a/doc/mal-och-tester.md b/doc/mal-och-tester.md index d10321a..c30209b 100644 --- a/doc/mal-och-tester.md +++ b/doc/mal-och-tester.md @@ -34,7 +34,7 @@ att bygga på Linux som på Windows. | Inkrementellt debug-bygge | < 10 s | 4,7 s | | Release-binär | < 20 MB | 10,2 MB (Linux), 10,8 MB (Windows) | | Testsviten | `go test ./...` grön på < 30 s | ~0,1 s | -| Banor | 100 % passerar det automatiska lösbarhetstestet | 3 av 3 | +| Banor | 100 % passerar det automatiska lösbarhetstestet | 4 av 4 | | Windows-korsbygge från Linux | fungerar utan extra verktyg | ✓ (CGO_ENABLED=0) | | Linux-binärens länkade bibliotek | bara libc | ✓ (GL/X11 laddas dynamiskt vid körning) | | Bildfrekvens | 60 fps vid 4× skala på integrerad grafik | ej mätt | @@ -118,6 +118,12 @@ att bygga på Linux som på Windows. | `internal/minigame` | `TestTopUpPicksMemberWithIndividualCards`, `TestSeatSelectionPreferences` | Personval och 2×2-rutnät; seedade önskemål, upptagna platser, missnöje, fönster/gång/sida/bredvid, Esc avbryter köpet | | `internal/level` | `TestSchedulePhases`, `TestScheduleValidationForPhases`, `TestTrackTile` | **M7b:** faserna away→approaching→arriving→dwelling→closing→leaving ur tidtabellen, ombordstigning bara med öppna dörrar, Status och Phase konsekventa; för korta uppehåll/borta-tider avvisas; rälsrutan | | `internal/sim` | `TestNearTrackAndPhases` | Närmaste perrong inom två rutor, inte tre; under inrullningen syns tåget men man kan inte kliva på; med öppna dörrar kan man | +| `internal/progress` | `TestShippedCatalogAndThirdRule`, `TestCatalogValidation` | **M8:** katalogerna parsar och validerar; **billigaste tredjedelen av items kostar mer än startkassan**; banornas belöningar/butiker pekar på riktiga items | +| `internal/progress` | `TestXPLevelsAndSkillPoints`, `TestSkillTreeRequirementsAndCost` | Nivåtrösklar, skillpoäng per level-up, krav och kostnad, effekter multipliceras | +| `internal/progress` | `TestItemsBuyEquipAndEffects`, `TestLevelRecordsAndUnlocking` | Köp, bär/ta av, en per plats, tint, genvägar; bästa resultat behålls, upplåsning av huvud- och sidobanor | +| `internal/save` | `TestRoundTrip`, `TestCorruptAndFutureVersions`, `TestDefaultPath` | Skriv → läs ger samma tillstånd inkl. seed; trasig/nyare fil ger fel; version 0 migreras | +| `internal/sim` | `TestRunOptionsFromProgression` | Extra stats, sikt, landmärken, gratis spärrar, hela kartan, powerbank | +| `internal/minigame` | `TestCrowdLuckWidensZone` | Tur gör zonen bredare, otur smalare, klampat | | `internal/archtest` | `TestOnlyPresentationPackagesImportEbitengine` | Beroenderegeln ovan | ### Planerade tester per milstolpe @@ -133,7 +139,7 @@ att bygga på Linux som på Windows. | ~~M7 Svårighet/poäng/debuffs~~ ✓ | Poäng, tidsgräns, debuffar, egna kort, sittplatsval – finns, se tabellen ovan | | ~~M7b Tåg som kommer och går~~ ✓ | Faser, ombordstigning, indikatoravstånd – finns, se tabellen ovan; ljud och animation verifieras manuellt/med skärmdumpar | | M9b Folk, reklam, ljudröra | NPC:er följer seedade mål utan att fastna; krock saktar ned; fel sida i gång bromsar; bot klarar bana med folk; fler NPC:er = längre tid; clutter/noise läses per bana | -| M8 Progression & sparfil | Sparfil round-trip (skriv → läs ger samma tillstånd inkl. seed); skill tree-krav; upplåsningar efter rätt bana; "< 33 %"-regeln kontrolleras mot itemlistan | +| ~~M8 Progression & sparfil~~ ✓ | Round-trip, skillkrav, upplåsningar, 33 %-regeln – finns, se tabellen ovan | | M9a Flera våningar | Trappa byter våning för Mover och följare; `Path` över våningar; lösbarhetstest med trappor; dimma/minikarta per våning; gamla banor = en våning | | M9 Navigeringslägen | Listläge genererar rätt ordning av tåg; skyltläge: 1–4 giltiga vägar; fel-riktnings-förlust triggar vid rätt slutstation | | M10 Story-innehåll | Alla banor lösbara (redan automatiskt); flerbytesbanor har sammanhängande byten; sista banan slutar på flygplatsen | diff --git a/doc/plan.md b/doc/plan.md index 540b59d..ec6edaa 100644 --- a/doc/plan.md +++ b/doc/plan.md @@ -34,7 +34,7 @@ brasse-linux01-runnern, inte Pi5:n. M0 skelett ✓ → M1 rörelse/kamera ✓ → M2 tåg/spärrar/vinst ✓ → M3 japanska/skyltar ✓ → M4 lämmeltåget ✓ → M5 minikarta ✓ → M6 kort/biljetter/minispel ✓ → M7 -svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler → M9a flera våningar → M9b folk/reklam/ljudröra → M9 navigeringslägen → +svårighet/poäng/debuffar ✓ → M7b tåg som kommer och går ✓ → M8 progression/sparfiler ✓ → M9a flera våningar → M9b folk/reklam/ljudröra → M9 navigeringslägen → M10 story-innehåll → M11 oändligt läge/NG+ → M12 ljud/polish/release. Innehåll per milstolpe: [roadmap.md › Milstolpar](roadmap.md#milstolpar). Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). @@ -62,5 +62,7 @@ Vad som ska vara testat per milstolpe: [mal-och-tester.md](mal-och-tester.md). - ~~Hur lär man sig ord utöver hjälp?~~ – beslutat av Björn 2026-10-23: bra resultat lär resmålet, läsning ger seedade framsteg, språkpoäng på valfria ord i ordlistan. Implementerat; trädet (M8) bygger vidare på listan. -- Ska Oändligt läge dela sparfil med story eller ha egen? +- Ska Oändligt läge dela sparfil med story eller ha egen? (Sparfilen har + nu ett versionsfält och `Current`-referens; oändligt läge kan få en egen + sektion i samma fil.) - Hur många huvudbanor siktar vi på i storyn? (Påverkar M10:s omfattning.) diff --git a/doc/roadmap.md b/doc/roadmap.md index 7c067fa..b2b3c7a 100644 --- a/doc/roadmap.md +++ b/doc/roadmap.md @@ -61,6 +61,8 @@ metro-puzzle-jp/ │ ├── sim/ rutbaserad rörelse, kollision, kamera, banomgång (Run), sällskap (Party), dimma (Fog) – ren Go │ ├── lang/ ordbok, tokenisering, kunskap, delvis översättning, engelska-andel, hjälptid – ren Go │ ├── minigame/ påfyllning, biljettköp, trängsel, tågresa – seedade, ren Go +│ ├── progress/ XP, skillträd, items, banresultat, upplåsning – ren Go +│ ├── save/ sparfil: versionerad JSON, migrering, atomisk skrivning │ ├── game/ Ebitengine-loop, scener, input, rendering, skriptläge ← får använda Ebitengine │ ├── archtest/ beroenderegler │ └── … kommande: lang/, nav/, score/, save/, minigame/, ui/, audio/ @@ -71,7 +73,7 @@ metro-puzzle-jp/ │ ├── sprites/ bilder, tilesets, UI │ ├── sound/ OGG/WAV │ ├── text/ sv.json, en.json, jp-dictionary.json, fonts/PixelMplus*.ttf -│ └── data/ levels/, items, skills (JSON) +│ └── data/ levels/, items.json, skills.json ├── doc/ denna dokumentation (doc/img/ = skärmdumpar från tools/shots) ├── .gitea/workflows/ CI: test + release-zippar för linux/windows på brasse-linux01-runnern ├── .vscode/ tasks.json, launch.json @@ -355,18 +357,40 @@ på **brasse-linux01-runnern** (label `linux-amd64`), aldrig på Pi5:n. - **Konsekvens för banorna:** uppehållet måste vara minst 5,5 s och tiden borta minst 7,5 s (valideras), så att faserna får plats. -### M8 – Progression, items och sparfiler +### M8 – Progression, items och sparfiler ✓ (klar 2026-10-24) - **Mål:** något att låsa upp och en anledning att fortsätta. -- XP/level-ups, skill tree, items (kläder, masker, hattar) med buffar. -- Bankarta i spelbrädesstil; sidobanor som ger skins/powers/items; items - som låser upp genvägar i huvudbanor. -- Sparfil med framsteg, items, skills, debuffar och seed. Autospar vid - banstart. -- **Teknik:** `internal/save` (JSON, versionsfält för migrering), - `resources/data/items.json`, `skills.json`. -- **Klar när:** sparfil round-trip-test grön; "< 33 % per omgång" går att - räkna ut från datan. +- **XP och nivåer:** en klarad bana ger `poäng/10 + 25 × stjärnor` XP; + nivå n+1 nås vid 150·n(n+1)/2 XP (150, 450, 900 …). Varje level-up ger + en **skillpoäng**. +- **Skillträd** (`resources/data/skills.json`, 12 noder i fyra grenar: + språk, kropp, resenär, utforskare): kana, fokus, hjälp, fart, törst, toa, + tur, sikt, landmärken. Krav och kostnad valideras; effekterna är + stats-multiplikatorer plus flaggor. Språkpoängen på ord (M3) finns kvar + som egen valuta. +- **Items** (`resources/data/items.json`, 25 st: hattar, masker, kläder, + skor, prylar, skins) med buffar, färgton på spelaren och genvägar: + JR Pass (gratis spärrar), kartor (hela minikartan på en bana), powerbank + (oändligt batteri). Ett item per plats (huvud, ansikte, kropp, fötter, + väska). Köps kontant i **kioskens butik** (B) – banans sortiment eller ett + seedat urval. Designregeln "knappt 33 % per omgång" är ett test: + billigaste tredjedelen kostar mer än startkassan. +- **Bankartan** i spelbrädesstil ersätter menyn: huvudbanorna på en linje, + sidospår ovanför, låsta grå, klarade med stjärnor; nästa huvudbana öppnas + när föregående är klarad, sidospår när föräldern är. Ny sidobana + **002b Kabukicho** belönar skinnet Kimono och en skillpoäng. +- **Sparfil** (`internal/save`): versionerad JSON med kunskap, plånbok, + debuffar, progression och pågående bana + seed, atomisk skrivning, + migrering av äldre filer. `~/.config/metro-puzzle-jp/save.json` på Linux, + `%AppData%` på Windows. Autospar vid banstart (seeden), efter banan och + vid köp/upplåsningar. `⟳` på kartan betyder att banan återupptas med samma + slump. Flaggor `-save PATH`, `-nosave`. +- **Teknik:** `internal/progress` (ren Go, 6 tester), `internal/save` + (3 tester), `sim.RunOptions` får Extra/FogRadius/Landmarks/FreeGates/ + RevealAll/InfiniteBattery, luck påverkar trängselzonen, nya scener + karta/skills/garderob och butiken i `internal/game`. +- **Klar när:** sparfil round-trip-test grön; "< 33 %" kontrolleras mot + itemlistan; skillkrav, upplåsningar och belöningar testade. ✓ ### M9a – Flera våningar (Björns tillägg 2026-10-24) diff --git a/internal/game/game.go b/internal/game/game.go index 042cc9a..29aeb73 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -5,10 +5,17 @@ package game import ( + "fmt" + "log" + "github.com/hajimehoshi/ebiten/v2" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/rng" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" ) @@ -21,7 +28,7 @@ const ( dt = 1.0 / 60 // Ebitengine kör Update med fast 60 TPS ) -// Scene är ett spelläge: meny, bana, resultat … +// Scene är ett spelläge: bankarta, bana, resultat … type Scene interface { Update(g *Game) error Draw(g *Game, screen *ebiten.Image) @@ -29,12 +36,18 @@ type Scene interface { // Game är Ebitengine-tillståndet för hela programmet. type Game struct { - Assets *Assets - Know *lang.Knowledge // spelarens japanska – följer med mellan banor (sparas i M8) - Wallet *sim.Wallet // pengar, kort, telefon, biljett – följer med mellan banor - Profile *sim.Profile // aktiva debuffar – följer med mellan banor - Audio *Audio // nil = tyst - fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) + Assets *Assets + State *save.State // allt som sparas + Know *lang.Knowledge // = State.Know + Wallet *sim.Wallet // = State.Wallet + Profile *sim.Profile // = State.Profile + Progress *progress.Progress // = State.Progress + Catalog *progress.Catalog + Levels []*level.Level // alla banor, sorterade på id + Audio *Audio // nil = tyst + + savePath string // "" = spara inte + fixedSeed uint64 // >0: alla banor startar med denna seed (tester/skärmdumpar) scene Scene next Scene smokeLeft int // >0: avsluta automatiskt efter så många uppdateringar @@ -44,62 +57,113 @@ type Game struct { // Options styr uppstarten. type Options struct { - LevelID string // hoppa direkt in i en bana (tom = meny) + LevelID string // hoppa direkt in i en bana (tom = bankartan) SmokeFrames int // >0 avslutar efter N uppdateringar (smoke-test) Script string // skriptade steg för skärmdumpar/tester, se script.go KnownWords []string // ord spelaren kan från start (för tester/skärmdumpar) Points int // språkpoäng från start (för tester/skärmdumpar) Seed uint64 // >0: fast seed för banorna (för tester/skärmdumpar) Phone bool // kortet i telefonen från start + CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard Debuffs []string // debuffar från start (för tester/skärmdumpar) Mute bool // inget ljud - CardYen int // >=0: kortets startsaldo (för tester/skärmdumpar), -1 = standard + SavePath string // sparfil ("" = standardplatsen) + NoSave bool // läs sparfilen men skriv aldrig } -// New laddar resurser och bygger spelet. +// New laddar resurser, sparfil och bygger spelet. func New(opt Options) (*Game, error) { a, err := LoadAssets() if err != nil { return nil, err } - g := &Game{Assets: a, Know: lang.NewKnowledge(), smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH)} - for _, w := range opt.KnownWords { - g.Know.Learn(w) - } - g.Know.Points = opt.Points - g.fixedSeed = opt.Seed - if g.Audio, err = newAudio(opt.Mute); err != nil { - return nil, err - } - g.Wallet = sim.NewWallet() - g.Profile = &sim.Profile{} - for _, d := range opt.Debuffs { - g.Profile.Add(sim.DebuffKind(d), 1) - } - g.Wallet.Phone = opt.Phone - if opt.CardYen >= 0 { - g.Wallet.Card = opt.CardYen - } + g := &Game{Assets: a, smokeLeft: opt.SmokeFrames, frame: ebiten.NewImage(ScreenW, ScreenH), fixedSeed: opt.Seed} if opt.Script != "" { if g.script, err = parseScript(opt.Script); err != nil { return nil, err } } + if g.Audio, err = newAudio(opt.Mute); err != nil { + return nil, err + } + if g.Catalog, err = loadCatalog(); err != nil { + return nil, err + } + if g.Levels, err = level.LoadAll(a.LevelsDir); err != nil { + return nil, err + } + + // Sparfil. + path := opt.SavePath + if path == "" { + if path, err = save.DefaultPath(); err != nil { + return nil, err + } + } + st, exists, err := save.Load(path) + if err != nil { + return nil, err + } + if exists { + log.Printf("sparfil läst: %s", path) + } + g.State = st + g.Know, g.Wallet, g.Profile, g.Progress = st.Know, st.Wallet, st.Profile, st.Progress + if !opt.NoSave { + g.savePath = path + } + + // Startvärden för tester/skärmdumpar. + for _, w := range opt.KnownWords { + g.Know.Learn(w) + } + if opt.Points > 0 { + g.Know.Points = opt.Points + } + for _, d := range opt.Debuffs { + g.Profile.Add(sim.DebuffKind(d), 1) + } + if opt.Phone { + g.Wallet.Phone = true + } + if opt.CardYen >= 0 { + g.Wallet.Card = opt.CardYen + } + if opt.LevelID != "" { - s, err := NewLevelScene(a, opt.LevelID, g, g.seed()) + s, err := g.startLevel(opt.LevelID) if err != nil { return nil, err } g.scene = s } else { - g.scene, err = NewMenuScene(a) - if err != nil { - return nil, err - } + g.scene = NewMapScene(g) } return g, nil } +func loadCatalog() (*progress.Catalog, error) { + sk, err := res.ReadFile("data", "skills.json") + if err != nil { + return nil, err + } + it, err := res.ReadFile("data", "items.json") + if err != nil { + return nil, err + } + return progress.LoadCatalog(sk, it) +} + +// Save skriver sparfilen (no-op utan sparväg). +func (g *Game) Save() { + if g.savePath == "" { + return + } + if err := save.Store(g.savePath, g.State); err != nil { + log.Printf("kunde inte spara: %v", err) + } +} + // SetScene byter scen från och med nästa uppdatering. func (g *Game) SetScene(s Scene) { g.next = s } @@ -131,6 +195,106 @@ func (g *Game) Draw(screen *ebiten.Image) { // Layout talar om för Ebitengine vilken intern upplösning vi ritar i. func (g *Game) Layout(outsideWidth, outsideHeight int) (int, int) { return ScreenW, ScreenH } +// LevelByID slår upp en bana. +func (g *Game) LevelByID(id string) *level.Level { + for _, l := range g.Levels { + if l.ID == id { + return l + } + } + return nil +} + +// Effects är de sammanlagda effekterna av skills och burna items. +func (g *Game) Effects() progress.Aggregate { return g.Progress.Effects(g.Catalog) } + +// runOptions bygger banans inställningar från progressionen. +func (g *Game) runOptions(id string, seed rng.Seed) sim.RunOptions { + eff := g.Effects() + if eff.Kana { + g.Know.Kana = true + } + return sim.RunOptions{ + Dict: g.Assets.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed, + Extra: eff.Stats, FogRadius: eff.FogRadius, Landmarks: eff.Landmarks, FreeGates: eff.FreeGates, + RevealAll: eff.Reveal[id], InfiniteBattery: eff.Battery, + } +} + +// startLevel startar (eller återupptar) en bana och sparar seeden så att en +// laddad omgång ger samma slump. +func (g *Game) startLevel(id string) (*LevelScene, error) { + if g.LevelByID(id) == nil { + return nil, fmt.Errorf("okänd bana %q", id) + } + var seed rng.Seed + switch { + case g.fixedSeed > 0: + seed = rng.FromInt(g.fixedSeed) + case g.State.Current != nil && g.State.Current.LevelID == id: + seed = rng.Seed{A: g.State.Current.Seed[0], B: g.State.Current.Seed[1]} // återuppta med samma slump + default: + seed = rng.NewSeed() + } + s, err := NewLevelScene(g, id, seed) + if err != nil { + return nil, err + } + g.State.Current = &save.RunRef{LevelID: id, Seed: [2]uint64{seed.A, seed.B}} + g.Save() + return s, nil +} + +// LevelOutcome är vad en avslutad bana gav i progression (för resultatskärmen). +type LevelOutcome struct { + XP int + LevelUps int + FirstClear bool + NewItems []string + NewPoints int + Unlocked []string // banor som öppnades +} + +// levelEnded appliceras exakt en gång när en bana avgjorts: debuffar, +// banresultat, XP, belöningar och upplåsningar; sedan sparas allt. +func (g *Game) levelEnded(run *sim.Run, id string) LevelOutcome { + var out LevelOutcome + for _, d := range run.NewDebuffs { + g.Profile.Add(d.Kind, d.LevelsLeft) + } + g.Profile.EndLevel() + + before := map[string]bool{} + for _, l := range g.Levels { + before[l.ID] = g.Progress.Unlocked(g.Levels, l.ID) + } + won := run.Outcome == sim.Won + out.FirstClear = g.Progress.RecordLevel(id, won, run.Stars, run.Score.Total) + if won { + out.XP = progress.XPFor(run.Score.Total, run.Stars) + out.LevelUps = g.Progress.AddXP(out.XP) + if out.FirstClear { + if l := g.LevelByID(id); l != nil { + for _, it := range l.Rewards.Items { + if g.Progress.Give(g.Catalog, it) { + out.NewItems = append(out.NewItems, it) + } + } + out.NewPoints = l.Rewards.SkillPoints + g.Progress.SkillPoints += l.Rewards.SkillPoints + } + } + for _, l := range g.Levels { + if !before[l.ID] && g.Progress.Unlocked(g.Levels, l.ID) { + out.Unlocked = append(out.Unlocked, l.ID) + } + } + } + g.State.Current = nil + g.Save() + return out +} + // seed ger seeden för en ny banomgång: fast om -seed angetts, annars färsk. func (g *Game) seed() rng.Seed { if g.fixedSeed > 0 { @@ -139,11 +303,5 @@ func (g *Game) seed() rng.Seed { return rng.NewSeed() } -// levelEnded appliceras exakt en gång när en bana avgjorts: banans nya -// debuffar läggs på profilen och alla räknas ned en bana. -func (g *Game) levelEnded(run *sim.Run) { - for _, d := range run.NewDebuffs { - g.Profile.Add(d.Kind, d.LevelsLeft) - } - g.Profile.EndLevel() -} +// progress_XPToNext är ett kort alias för scenerna. +func progress_XPToNext(xp int) int { return progress.XPToNext(xp) } diff --git a/internal/game/input.go b/internal/game/input.go index cc6d325..24fc4cb 100644 --- a/internal/game/input.go +++ b/internal/game/input.go @@ -114,6 +114,15 @@ func confirmHeld() bool { return false } +// skillsJustPressed: T = skillträdet. +func skillsJustPressed() bool { return virtual.pressed["t"] || inpututil.IsKeyJustPressed(ebiten.KeyT) } + +// itemsJustPressed: I = garderoben/items. +func itemsJustPressed() bool { return virtual.pressed["i"] || inpututil.IsKeyJustPressed(ebiten.KeyI) } + +// shopJustPressed: B = butiken i kiosken. +func shopJustPressed() bool { return virtual.pressed["b"] || inpututil.IsKeyJustPressed(ebiten.KeyB) } + // phoneJustPressed: P = kortet i telefonen av/på (meny). func phoneJustPressed() bool { return virtual.pressed["p"] || inpututil.IsKeyJustPressed(ebiten.KeyP) } diff --git a/internal/game/scene_items.go b/internal/game/scene_items.go new file mode 100644 index 0000000..c45e54f --- /dev/null +++ b/internal/game/scene_items.go @@ -0,0 +1,144 @@ +package game + +import ( + "fmt" + "image/color" + "sort" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" +) + +// ItemsScene är garderoben: ägda items per plats, Enter bär/tar av. +type ItemsScene struct { + back Scene + items []progress.Item + sel int + top int + msg string +} + +const itemRows = 8 + +// NewItemsScene listar ägda items, plats för plats. +func NewItemsScene(g *Game, back Scene) *ItemsScene { + var owned []progress.Item + for _, it := range g.Catalog.Items { + if g.Progress.Own(it.ID) { + owned = append(owned, it) + } + } + slotOrder := map[string]int{} + for i, s := range progress.Slots { + slotOrder[s] = i + } + sort.SliceStable(owned, func(i, j int) bool { + if owned[i].Slot != owned[j].Slot { + return slotOrder[owned[i].Slot] < slotOrder[owned[j].Slot] + } + return owned[i].Price < owned[j].Price + }) + return &ItemsScene{back: back, items: owned} +} + +func (s *ItemsScene) Update(g *Game) error { + if backJustPressed() || itemsJustPressed() { + g.SetScene(s.back) + return nil + } + if n := len(s.items); n > 0 { + s.sel = (s.sel + menuMove() + n) % n + if s.sel < s.top { + s.top = s.sel + } + if s.sel >= s.top+itemRows { + s.top = s.sel - itemRows + 1 + } + if confirmJustPressed() { + it := s.items[s.sel] + if g.Progress.Equipped[it.Slot] == it.ID { + g.Progress.Unequip(it.Slot) + s.msg = fmt.Sprintf(g.Assets.T("items.removed"), it.Name.Get(UILang)) + } else { + g.Progress.Equip(g.Catalog, it.ID) + s.msg = fmt.Sprintf(g.Assets.T("items.worn"), it.Name.Get(UILang)) + } + g.Save() + } + } + return nil +} + +// effectText beskriver en effekt kort. +func effectText(a *Assets, e progress.Effect) string { + var parts []string + keys := make([]string, 0, len(e.Stats)) + for k := range e.Stats { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s %+.0f%%", a.T("stat."+k), (e.Stats[k]-1)*100)) + } + if e.Kana { + parts = append(parts, a.T("effect.kana")) + } + if e.FogRadius > 0 { + parts = append(parts, fmt.Sprintf(a.T("effect.fog"), e.FogRadius)) + } + if e.Landmarks { + parts = append(parts, a.T("effect.landmarks")) + } + if e.FreeGates { + parts = append(parts, a.T("effect.free_gates")) + } + if e.RevealLevel != "" { + parts = append(parts, fmt.Sprintf(a.T("effect.reveal"), e.RevealLevel)) + } + if e.Battery { + parts = append(parts, a.T("effect.battery")) + } + return strings.Join(parts, ", ") +} + +func (s *ItemsScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + screen.Fill(color.RGBA{24, 20, 37, 255}) + vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false) + a.DrawBig(screen, a.T("items.title"), 8, 5, colText) + hdr := fmt.Sprintf("%d/%d %s %s %d¥", len(s.items), len(g.Catalog.Items), a.T("items.owned"), a.T("hud.cash"), g.Wallet.Cash) + a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown) + + y0 := 30 + for i := s.top; i < len(s.items) && i < s.top+itemRows; i++ { + it := s.items[i] + y := y0 + (i-s.top)*LineH + if i == s.sel { + vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false) + } + col, status := colText, "" + if p.Equipped[it.Slot] == it.ID { + col, status = colGood, a.T("items.wearing") + } + a.Draw(screen, a.T("slot."+it.Slot), 8, y, colDim) + a.Draw(screen, it.Name.Get(UILang), 60, y, col) + a.Draw(screen, effectText(a, it.Effect), 150, y, colDim) + a.Draw(screen, status, ScreenW-8-a.Width(status), y, col) + } + if len(s.items) == 0 { + a.Draw(screen, a.T("items.none"), 8, y0, colDim) + } + eff := g.Effects() + sum := fmt.Sprintf("%s: %s %.0f%% %s %.0f%% %s %.0f%% %s %.0f%% %s %.0f%%", a.T("items.total"), + a.T("stat.speed"), 100*eff.Stats[0], a.T("stat.thirst"), 100*eff.Stats[1], a.T("stat.focus"), 100*eff.Stats[2], + a.T("stat.toilet"), 100*eff.Stats[3], a.T("stat.luck"), 100*eff.Stats[4]) + a.Draw(screen, sum, 8, 134, colDim) + if s.msg != "" { + a.Draw(screen, s.msg, 8, ScreenH-26, colKnown) + } + a.Draw(screen, a.T("items.hint"), 8, ScreenH-13, colDim) +} diff --git a/internal/game/scene_level.go b/internal/game/scene_level.go index bb2f06e..9dda08d 100644 --- a/internal/game/scene_level.go +++ b/internal/game/scene_level.go @@ -36,6 +36,9 @@ type LevelScene struct { animT float64 hideMap bool // M döljer/visar minikartan + shop *shop // öppen butik i kiosken (pausar banan) + outcome LevelOutcome // progression från banan (fylls i när den avgjorts) + // Tåg och effekter (M7b). tracks []trackInfo rails map[level.Point]bool @@ -45,15 +48,16 @@ type LevelScene struct { } // NewLevelScene laddar banan och ställer spelaren på startrutan. Kunskap, -// plånbok och debuffar tas från Game (delas mellan banor); seed styr slumpen. -func NewLevelScene(a *Assets, id string, g *Game, seed rng.Seed) (*LevelScene, error) { +// plånbok, debuffar, skills och items tas från Game; seed styr slumpen. +func NewLevelScene(g *Game, id string, seed rng.Seed) (*LevelScene, error) { + a := g.Assets lvl, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) if err != nil { return nil, err } s := &LevelScene{ id: id, - run: sim.NewRun(lvl, sim.RunOptions{Dict: a.Dict, Know: g.Know, Wallet: g.Wallet, Debuffs: g.Profile.Debuffs, Seed: seed}), + run: sim.NewRun(lvl, g.runOptions(id, seed)), cam: sim.NewCamera(ScreenW, ScreenH, float64(lvl.Width()*TileSize), float64(lvl.Height()*TileSize)), } s.cam.Margin = TileSize // rälsen ligger i kantraden: visa en ruta utanför kartan @@ -102,23 +106,27 @@ func (s *LevelScene) followPlayer() { } func (s *LevelScene) Update(g *Game) error { + if s.shop != nil { // butiken pausar banan + s.updateShop(g) + return nil + } s.updateEffects(g) if s.run.Mini != nil { // Minispel: all input går dit; Esc avbryter minispelet, inte banan. mx, my := miniMoveXY() s.run.StepMini(dt, minigame.Input{Move: my, MoveX: mx, Confirm: confirmJustPressed(), Hold: confirmHeld(), Cancel: backJustPressed()}) if s.run.Outcome != sim.Ongoing { - g.levelEnded(s.run) + s.outcome = g.levelEnded(s.run, s.id) g.SetScene(NewResultScene(s)) } return nil } if backJustPressed() { - m, err := NewMenuScene(g.Assets) - if err != nil { - return err - } - g.SetScene(m) + g.SetScene(NewMapScene(g)) + return nil + } + if shopJustPressed() && !s.run.Player.Moving() && s.run.Level.At(s.run.Player.Tile.X, s.run.Player.Tile.Y) == level.Kiosk { + s.openShop(g) return nil } if helpJustPressed() { @@ -135,7 +143,7 @@ func (s *LevelScene) Update(g *Game) error { } s.followPlayer() if s.run.Outcome != sim.Ongoing { - g.levelEnded(s.run) + s.outcome = g.levelEnded(s.run, s.id) g.SetScene(NewResultScene(s)) } return nil @@ -227,7 +235,11 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { px, py := leader.WorldPos(TileSize) op.GeoM.Reset() op.GeoM.Translate(ox+math.Round(px), oy+math.Round(py)) + tint := g.Effects().Tint + op.ColorScale.Reset() + op.ColorScale.Scale(tint[0], tint[1], tint[2], 1) screen.DrawImage(g.Assets.PlayerFrame(leader.Facing, leader.Moving(), s.animT), &op) + op.ColorScale.Reset() s.drawUrge(g, screen, s.run.Party.Members[0], int(ox+math.Round(px)), int(oy+math.Round(py))) s.drawIndicator(screen, ox, oy) @@ -235,6 +247,9 @@ func (s *LevelScene) Draw(g *Game, screen *ebiten.Image) { if s.run.Mini != nil { s.drawMinigame(g, screen) } + if s.shop != nil { + s.drawShop(g, screen) + } } // memberTints färgar följarnas sprites tills de har egna. @@ -308,8 +323,8 @@ func (s *LevelScene) drawHUD(g *Game, screen *ebiten.Image) { a.Draw(screen, wallet, 4, LineH, walletCol) s.drawMeters(g, screen) - if r.Outcome != sim.Ongoing || r.Mini != nil { - return // resultatpanelen eller minispelet tar över; ingen inforuta eller minikarta + if r.Outcome != sim.Ongoing || r.Mini != nil || s.shop != nil { + return // resultatpanelen, minispelet eller butiken tar över; ingen inforuta eller minikarta } lines, accent, bar := s.infoLines(a) if entry := a.Dict.Lookup(r.Learned); entry != nil && r.Time-r.LearnedAt < learnedShowSec { @@ -457,9 +472,9 @@ func (s *LevelScene) infoLines(a *Assets) (lines []line, accent color.Color, bar return []line{plainLine(a.T("hud.toilet_none"), colDim)}, accent, false case level.Kiosk: if n := len(r.Party.NeedsKiosk()); n > 0 { - return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n), colGood)}, colGood, false + return []line{plainLine(fmt.Sprintf(a.T("hud.kiosk_prompt"), n, sim.KioskPriceYen*n)+" "+a.T("hud.shop_prompt"), colGood)}, colGood, false } - return []line{plainLine(a.T("hud.kiosk_none"), colDim)}, accent, false + return []line{plainLine(a.T("hud.kiosk_none")+" "+a.T("hud.shop_prompt"), colDim)}, accent, false case level.TicketMachine: if lvl.TicketRequired { return []line{plainLine(fmt.Sprintf(a.T("hud.machine_ticket"), r.Wallet.Cash), colGood)}, colGood, false diff --git a/internal/game/scene_map.go b/internal/game/scene_map.go new file mode 100644 index 0000000..f13972f --- /dev/null +++ b/internal/game/scene_map.go @@ -0,0 +1,227 @@ +package game + +import ( + "fmt" + "image/color" + "sort" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" +) + +// MapScene är bankartan i spelbrädesstil: huvudbanorna på en slingrande +// linje från flygplatsen, sidobanorna som avstickare ovanför. Låsta banor +// är grå, klarade visar stjärnor. +type MapScene struct { + mains []*level.Level + sides map[string][]*level.Level // parent -> sidobanor + sel int // vald huvudbana + side int // 0 = huvudbanan, 1.. = sidobana nr + msg string +} + +type mapNode struct { + lvl *level.Level + x, y int +} + +// NewMapScene bygger kartan från alla banor. +func NewMapScene(g *Game) *MapScene { + m := &MapScene{sides: map[string][]*level.Level{}} + for _, l := range g.Levels { + if l.IsSide() { + m.sides[l.Parent] = append(m.sides[l.Parent], l) + } else { + m.mains = append(m.mains, l) + } + } + sort.Slice(m.mains, func(i, j int) bool { return m.mains[i].Order < m.mains[j].Order }) + // Välj den första banan som inte är klarad. + for i, l := range m.mains { + if !g.Progress.Cleared(l.ID) { + m.sel = i + break + } + } + return m +} + +// nodes ger positionerna på kartan. +func (m *MapScene) nodes() ([]mapNode, map[string][]mapNode) { + n := len(m.mains) + var mains []mapNode + sides := map[string][]mapNode{} + for i, l := range m.mains { + x := 36 + if n > 1 { + x = 36 + i*(ScreenW-72)/(n-1) + } + y := 104 + if i%2 == 1 { + y = 116 + } + mains = append(mains, mapNode{l, x, y}) + for k, s := range m.sides[l.ID] { + sides[l.ID] = append(sides[l.ID], mapNode{s, x + 12 + k*18, y - 40}) + } + } + return mains, sides +} + +func (m *MapScene) current() *level.Level { + if len(m.mains) == 0 { + return nil + } + main := m.mains[m.sel] + if m.side > 0 { + if s := m.sides[main.ID]; m.side-1 < len(s) { + return s[m.side-1] + } + } + return main +} + +func (m *MapScene) Update(g *Game) error { + switch { + case backJustPressed(): + return ebiten.Termination + case wordsJustPressed(): + g.SetScene(NewWordsScene(g.Assets, m)) + return nil + case skillsJustPressed(): + g.SetScene(NewSkillsScene(g, m)) + return nil + case itemsJustPressed(): + g.SetScene(NewItemsScene(g, m)) + return nil + case phoneJustPressed(): + g.Wallet.Phone = !g.Wallet.Phone + g.Save() + } + if n := len(m.mains); n > 0 { + mx, my := miniMoveXY() + if mx != 0 { + m.sel = (m.sel + mx + n) % n + m.side = 0 + } + if my != 0 { + sides := len(m.sides[m.mains[m.sel].ID]) + m.side = max(0, min(sides, m.side-my)) // upp = ut på sidospåret + } + if confirmJustPressed() { + l := m.current() + if !g.Progress.Unlocked(g.Levels, l.ID) { + m.msg = g.Assets.T("map.locked") + return nil + } + s, err := g.startLevel(l.ID) + if err != nil { + m.msg = err.Error() + return nil + } + g.SetScene(s) + } + } + return nil +} + +func (m *MapScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + screen.Fill(color.RGBA{24, 20, 37, 255}) + vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false) + a.DrawBig(screen, a.T("game.title"), 8, 5, colText) + hdr := fmt.Sprintf("%s %d %s %d/%d ★ %d", a.T("map.level"), p.Level(), a.T("map.xp"), p.XP, p.XP+progress_XPToNext(p.XP), p.TotalStars()) + a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown) + + mains, sides := m.nodes() + // Linjen mellan huvudbanorna. + for i := 1; i < len(mains); i++ { + drawLine(screen, mains[i-1].x, mains[i-1].y, mains[i].x, mains[i].y, colBar) + } + for _, mn := range mains { + for _, sn := range sides[mn.lvl.ID] { + drawLine(screen, mn.x, mn.y, sn.x, sn.y, colBar) + } + } + cur := m.current() + drawNode := func(n mapNode, side bool) { + sz := float32(14) + if side { + sz = 10 + } + col := colDim + unlocked := p.Unlocked(g.Levels, n.lvl.ID) + switch { + case p.Cleared(n.lvl.ID): + col = colGood + case unlocked: + col = colText + } + vector.DrawFilledRect(screen, float32(n.x)-sz/2, float32(n.y)-sz/2, sz, sz, col, false) + if n.lvl == cur { + vector.DrawFilledRect(screen, float32(n.x)-sz/2-2, float32(n.y)-sz/2-2, sz+4, 2, colKnown, false) + vector.DrawFilledRect(screen, float32(n.x)-sz/2-2, float32(n.y)+sz/2, sz+4, 2, colKnown, false) + } + if r := p.Levels[n.lvl.ID]; r != nil && r.Cleared { + st := strings.Repeat("★", r.Stars) + a.Draw(screen, st, n.x-a.Width(st)/2, n.y+int(sz)/2+2, colWarn) + } + if !unlocked { // lås: en liten bygel + låda + vector.DrawFilledRect(screen, float32(n.x)-3, float32(n.y)-1, 6, 5, colPanel, false) + vector.DrawFilledRect(screen, float32(n.x)-2, float32(n.y)-4, 4, 3, colPanel, false) + vector.DrawFilledRect(screen, float32(n.x)-1, float32(n.y)-3, 2, 2, colDim, false) + } + } + for _, n := range mains { + drawNode(n, false) + for _, s := range sides[n.lvl.ID] { + drawNode(s, true) + } + } + // Info om vald bana. + if cur != nil { + name := cur.Name.Get(UILang) + " " + cur.Name.Get("ja") + if cur.IsSide() { + name = a.T("map.side") + ": " + name + } + a.Draw(screen, name, 8, 134, colText) + info := fmt.Sprintf("→ %s %s %s %s %d", cur.Destination.Get("ja"), cur.Destination.Get("romaji"), + strings.Repeat("★", cur.Difficulty)+strings.Repeat("☆", 5-cur.Difficulty), a.T("map.party"), cur.Party) + if g.State.Current != nil && g.State.Current.LevelID == cur.ID { + info += " " + a.T("map.resume") + } + a.Draw(screen, info, 8, 146, colDim) + if !p.Unlocked(g.Levels, cur.ID) { + a.Draw(screen, a.T("map.locked"), 150, 30, colBad) + } else if m.msg != "" { + a.Draw(screen, m.msg, 150, 30, colBad) + } + } + onoff := a.T("menu.off") + if g.Wallet.Phone { + onoff = a.T("menu.on") + } + foot := fmt.Sprintf("%s %d %s %d %s %d¥", a.T("map.skill_points"), p.SkillPoints, a.T("words.points"), g.Know.Points, + a.T("hud.cash"), g.Wallet.Cash) + a.Draw(screen, foot, 8, 30, colDim) + a.Draw(screen, fmt.Sprintf(a.T("menu.phone"), onoff), 8, 42, colDim) + if len(g.Profile.Debuffs) > 0 { + var parts []string + for _, d := range g.Profile.Debuffs { + parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) + } + a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, 54, colBad) + } + a.Draw(screen, a.T("map.hint"), 8, ScreenH-24, colDim) + a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-12, colDim) +} + +// drawLine ritar en tjock linje mellan två punkter. +func drawLine(screen *ebiten.Image, x0, y0, x1, y1 int, col color.Color) { + vector.StrokeLine(screen, float32(x0), float32(y0), float32(x1), float32(y1), 2, col, false) +} diff --git a/internal/game/scene_menu.go b/internal/game/scene_menu.go deleted file mode 100644 index cd1ea64..0000000 --- a/internal/game/scene_menu.go +++ /dev/null @@ -1,112 +0,0 @@ -package game - -import ( - "fmt" - "image/color" - "path/filepath" - "strings" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" - - "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/buildinfo" - "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" -) - -type menuEntry struct { - id string - name string - dest string - stars string -} - -// MenuScene listar banorna och låter spelaren välja. -type MenuScene struct { - entries []menuEntry - sel int - err error -} - -// NewMenuScene läser in banlistan. -func NewMenuScene(a *Assets) (*MenuScene, error) { - ids, err := level.List(a.LevelsDir) - if err != nil { - return nil, err - } - m := &MenuScene{} - for _, id := range ids { - l, err := level.LoadFile(filepath.Join(a.LevelsDir, id+".json")) - if err != nil { - m.err = err - continue - } - m.entries = append(m.entries, menuEntry{ - id: id, - name: l.Name.Get(UILang) + " " + l.Name.Get("ja"), - dest: l.Destination.Get("ja") + " " + l.Destination.Get("romaji"), - stars: strings.Repeat("★", l.Difficulty) + strings.Repeat("☆", 5-l.Difficulty), - }) - } - return m, nil -} - -func (m *MenuScene) Update(g *Game) error { - if backJustPressed() { - return ebiten.Termination - } - if wordsJustPressed() { - g.SetScene(NewWordsScene(g.Assets, m)) - return nil - } - if phoneJustPressed() { - g.Wallet.Phone = !g.Wallet.Phone - } - if n := len(m.entries); n > 0 { - m.sel = (m.sel + menuMove() + n) % n - if confirmJustPressed() { - s, err := NewLevelScene(g.Assets, m.entries[m.sel].id, g, g.seed()) - if err != nil { - m.err = err - return nil - } - g.SetScene(s) - } - } - return nil -} - -func (m *MenuScene) Draw(g *Game, screen *ebiten.Image) { - a := g.Assets - screen.Fill(color.RGBA{24, 20, 37, 255}) - vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false) - a.DrawBig(screen, a.T("game.title"), 8, 5, colText) - a.Draw(screen, a.T("menu.choose_level"), 8, 30, colDim) - - for i, e := range m.entries { - y := 46 + i*14 - if i == m.sel { - vector.DrawFilledRect(screen, 6, float32(y-2), ScreenW-12, 13, colBar, false) - } - a.Draw(screen, e.name, 12, y, colText) - a.Draw(screen, "→ "+e.dest, 150, y, colKnown) - a.Draw(screen, e.stars, ScreenW-62, y, colWarn) - } - if m.err != nil { - a.Draw(screen, "FEL: "+m.err.Error(), 8, ScreenH-40, colBad) - } - onoff := a.T("menu.off") - if g.Wallet.Phone { - onoff = a.T("menu.on") - } - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d %s %d¥ %s %d¥ %s", a.T("hud.japanese"), g.Know.Level(), g.Know.Count(), a.T("hud.words"), - a.T("words.points"), g.Know.Points, a.T("hud.card"), g.Wallet.Card, a.T("hud.cash"), g.Wallet.Cash, fmt.Sprintf(a.T("menu.phone"), onoff)), 8, ScreenH-38, colDim) - if len(g.Profile.Debuffs) > 0 { - var parts []string - for _, d := range g.Profile.Debuffs { - parts = append(parts, fmt.Sprintf("%s (%d)", a.T("debuff."+string(d.Kind)), d.LevelsLeft)) - } - a.Draw(screen, a.T("hud.debuffs")+": "+strings.Join(parts, ", "), 8, ScreenH-50, colBad) - } - a.Draw(screen, a.T("menu.hint"), 8, ScreenH-26, colDim) - a.Draw(screen, fmt.Sprintf("%s (%s)", buildinfo.Version, buildinfo.Mode), 8, ScreenH-13, colDim) -} diff --git a/internal/game/scene_result.go b/internal/game/scene_result.go index 09a3e9a..1efe3f0 100644 --- a/internal/game/scene_result.go +++ b/internal/game/scene_result.go @@ -23,17 +23,13 @@ func (r *ResultScene) Update(g *Game) error { case wordsJustPressed(): g.SetScene(NewWordsScene(g.Assets, r)) case inpututil_R(): - s, err := NewLevelScene(g.Assets, r.from.id, g, g.seed()) + s, err := g.startLevel(r.from.id) if err != nil { return err } g.SetScene(s) case confirmJustPressed() || backJustPressed(): - m, err := NewMenuScene(g.Assets) - if err != nil { - return err - } - g.SetScene(m) + g.SetScene(NewMapScene(g)) } return nil } @@ -42,7 +38,7 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { r.from.Draw(g, screen) // banan i bakgrunden run := r.from.run a := g.Assets - vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 138, colPanel, false) + vector.DrawFilledRect(screen, 20, 30, ScreenW-40, 144, colPanel, false) accent, headline := colBad, a.T("level.lost") if run.Outcome == sim.Won { @@ -96,6 +92,37 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, msg, 28, y, colKnown) y += LineH } + if o := r.from.outcome; run.Outcome == sim.Won { + prog := fmt.Sprintf("+%d %s", o.XP, a.T("map.xp")) + if o.LevelUps > 0 { + prog += fmt.Sprintf(" %s %d (+%d %s)", a.T("result.level_up"), g.Progress.Level(), o.LevelUps+o.NewPoints, a.T("map.skill_points")) + } else if o.NewPoints > 0 { + prog += fmt.Sprintf(" +%d %s", o.NewPoints, a.T("map.skill_points")) + } + if len(o.NewItems) > 0 { + var names []string + for _, id := range o.NewItems { + names = append(names, g.Catalog.Item(id).Name.Get(UILang)) + } + prog += " " + a.T("result.new_items") + ": " + strings.Join(names, ", ") + } + if len(o.Unlocked) > 0 { + var names []string + for _, id := range o.Unlocked { + if l := g.LevelByID(id); l != nil { + names = append(names, l.Name.Get(UILang)) + } + } + prog += " " + a.T("result.unlocked") + ": " + strings.Join(names, ", ") + } + for i, l := range a.wrapLine(plainLine(prog, colGood), ScreenW-56) { + if i > 1 { + break + } + a.drawLines(screen, []line{l}, 28, y) + y += LineH + } + } if len(run.NewDebuffs) > 0 { var parts []string for _, d := range run.NewDebuffs { @@ -104,7 +131,9 @@ func (r *ResultScene) Draw(g *Game, screen *ebiten.Image) { a.Draw(screen, a.T("result.new_debuffs")+": "+strings.Join(parts, ", "), 28, y, colBad) y += LineH } - a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), - a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim) - a.Draw(screen, a.T("result.hint"), 28, 155, colDim) + if y <= 149 { + a.Draw(screen, fmt.Sprintf("%s Lv%d %d %s %s %d (%d %s)", a.T("hud.japanese"), run.Know.Level(), run.Know.Count(), + a.T("hud.words"), a.T("words.points"), run.Know.Points, run.Know.Helps, a.T("result.helps")), 28, y, colDim) + } + a.Draw(screen, a.T("result.hint"), 28, 161, colDim) } diff --git a/internal/game/scene_skills.go b/internal/game/scene_skills.go new file mode 100644 index 0000000..6651861 --- /dev/null +++ b/internal/game/scene_skills.go @@ -0,0 +1,113 @@ +package game + +import ( + "fmt" + "image/color" + "sort" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" +) + +// SkillsScene är skillträdet: grenar med noder, kostnad i skillpoäng och krav. +type SkillsScene struct { + back Scene + skills []progress.Skill + sel int + top int + msg string +} + +const skillRows = 8 + +// NewSkillsScene listar skills gren för gren. +func NewSkillsScene(g *Game, back Scene) *SkillsScene { + sk := append([]progress.Skill(nil), g.Catalog.Skills...) + sort.SliceStable(sk, func(i, j int) bool { + if sk[i].Branch != sk[j].Branch { + return sk[i].Branch < sk[j].Branch + } + return len(sk[i].Requires) < len(sk[j].Requires) + }) + return &SkillsScene{back: back, skills: sk} +} + +func (s *SkillsScene) Update(g *Game) error { + if backJustPressed() || skillsJustPressed() { + g.SetScene(s.back) + return nil + } + if n := len(s.skills); n > 0 { + s.sel = (s.sel + menuMove() + n) % n + if s.sel < s.top { + s.top = s.sel + } + if s.sel >= s.top+skillRows { + s.top = s.sel - skillRows + 1 + } + if confirmJustPressed() { + sk := s.skills[s.sel] + switch { + case g.Progress.Has(sk.ID): + s.msg = g.Assets.T("skills.already") + case g.Progress.Unlock(g.Catalog, sk.ID): + s.msg = fmt.Sprintf(g.Assets.T("skills.unlocked"), sk.Name.Get(UILang)) + g.Save() + case g.Progress.SkillPoints < sk.Cost: + s.msg = fmt.Sprintf(g.Assets.T("skills.need_points"), sk.Cost) + default: + s.msg = g.Assets.T("skills.need_req") + } + } + } + return nil +} + +func (s *SkillsScene) Draw(g *Game, screen *ebiten.Image) { + a := g.Assets + p := g.Progress + screen.Fill(color.RGBA{24, 20, 37, 255}) + vector.DrawFilledRect(screen, 0, 0, ScreenW, 22, color.RGBA{38, 43, 68, 255}, false) + a.DrawBig(screen, a.T("skills.title"), 8, 5, colText) + hdr := fmt.Sprintf("%s %d %s %d %s %d", a.T("map.skill_points"), p.SkillPoints, a.T("map.level"), p.Level(), a.T("skills.to_next"), progress_XPToNext(p.XP)) + a.Draw(screen, hdr, ScreenW-8-a.Width(hdr), 6, colKnown) + + y0 := 30 + for i := s.top; i < len(s.skills) && i < s.top+skillRows; i++ { + sk := s.skills[i] + y := y0 + (i-s.top)*LineH + if i == s.sel { + vector.DrawFilledRect(screen, 4, float32(y-1), ScreenW-8, LineH, colBar, false) + } + col, status := colDim, strings.Repeat("●", sk.Cost) + switch { + case p.Has(sk.ID): + col, status = colGood, "✓" + case p.CanUnlock(g.Catalog, sk.ID): + col = colKnown + } + a.Draw(screen, sk.Branch, 8, y, colDim) + a.Draw(screen, sk.Name.Get(UILang), 80, y, col) + if len(sk.Requires) > 0 { + req := make([]string, len(sk.Requires)) + for k, r := range sk.Requires { + req[k] = g.Catalog.Skill(r).Name.Get(UILang) + } + a.Draw(screen, "← "+strings.Join(req, ", "), 190, y, colDim) + } + a.Draw(screen, status, ScreenW-8-a.Width(status), y, col) + } + if len(s.skills) > 0 { + sel := s.skills[s.sel] + for i, l := range a.wrapLine(plainLine(sel.Desc.Get(UILang), colText), ScreenW-16) { + a.drawLines(screen, []line{l}, 8, 130+i*LineH) + } + } + if s.msg != "" { + a.Draw(screen, s.msg, 8, ScreenH-26, colKnown) + } + a.Draw(screen, a.T("skills.hint"), 8, ScreenH-13, colDim) +} diff --git a/internal/game/scene_words.go b/internal/game/scene_words.go index c2be25e..badb4b4 100644 --- a/internal/game/scene_words.go +++ b/internal/game/scene_words.go @@ -60,6 +60,7 @@ func (w *WordsScene) Update(g *Game) error { w.msg = g.Assets.T("words.already") case g.Know.Buy(e): w.msg = fmt.Sprintf(g.Assets.T("words.bought"), e.Word, e.Translation(UILang)) + g.Save() default: w.msg = fmt.Sprintf(g.Assets.T("words.too_expensive"), lang.WordCost(e)) } diff --git a/internal/game/shop.go b/internal/game/shop.go new file mode 100644 index 0000000..7eaea31 --- /dev/null +++ b/internal/game/shop.go @@ -0,0 +1,104 @@ +package game + +import ( + "fmt" + "image" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" +) + +// Butiken i kiosken: banans sortiment (eller ett seedat urval), köps kontant. + +type shop struct { + items []progress.Item + cursor int + msg string +} + +const shopSize = 5 + +func (s *LevelScene) openShop(g *Game) { + sh := &shop{} + ids := s.run.Level.Shop + if len(ids) == 0 { + // Seedat urval ur allt köpbart, så att samma omgång ger samma butik. + all := g.Catalog.Purchasable() + r := s.run.Seed.Derive("shop").New() + r.Shuffle(len(all), func(i, j int) { all[i], all[j] = all[j], all[i] }) + for _, it := range all { + ids = append(ids, it.ID) + if len(ids) >= shopSize { + break + } + } + } + for _, id := range ids { + if it := g.Catalog.Item(id); it != nil { + sh.items = append(sh.items, *it) + } + } + s.shop = sh +} + +func (s *LevelScene) updateShop(g *Game) { + sh := s.shop + if backJustPressed() || shopJustPressed() { + s.shop = nil + return + } + if n := len(sh.items); n > 0 { + sh.cursor = (sh.cursor + menuMove() + n) % n + if confirmJustPressed() { + it := sh.items[sh.cursor] + switch { + case g.Progress.Own(it.ID): + sh.msg = g.Assets.T("shop.owned") + case g.Progress.Buy(g.Catalog, it.ID, g.Wallet.PayCash): + sh.msg = fmt.Sprintf(g.Assets.T("shop.bought"), it.Name.Get(UILang)) + g.Save() + default: + sh.msg = g.Assets.T("shop.too_expensive") + } + } + } +} + +func (s *LevelScene) drawShop(g *Game, screen *ebiten.Image) { + a := g.Assets + sh := s.shop + p := image.Rect(12, 28, ScreenW-12, ScreenH-10) + vector.DrawFilledRect(screen, float32(p.Min.X), float32(p.Min.Y), float32(p.Dx()), float32(p.Dy()), colPanel, false) + vector.DrawFilledRect(screen, float32(p.Min.X), float32(p.Min.Y), float32(p.Dx()), 3, colKnown, false) + x, y := p.Min.X+8, p.Min.Y+8 + a.DrawBig(screen, "売店", x, y, colKnown) + a.Draw(screen, fmt.Sprintf("%s %s %d¥", a.T("shop.title"), a.T("hud.cash"), g.Wallet.Cash), x+36, y+2, colText) + y += 24 + for i, it := range sh.items { + ry := y + i*LineH + if i == sh.cursor { + vector.DrawFilledRect(screen, float32(x-2), float32(ry-1), float32(p.Dx()-12), LineH, colBar, false) + } + col := colText + if g.Progress.Own(it.ID) { + col = colGood + } else if it.Price > g.Wallet.Cash { + col = colDim + } + a.Draw(screen, it.Name.Get(UILang), x, ry, col) + a.Draw(screen, effectText(a, it.Effect), x+110, ry, colDim) + price := fmt.Sprintf("%d円", it.Price) + if g.Progress.Own(it.ID) { + price = a.T("items.owned_short") + } + a.Draw(screen, price, p.Max.X-10-a.Width(price), ry, col) + } + if sh.msg != "" { + a.Draw(screen, sh.msg, x, p.Max.Y-28, colKnown) + } + a.Draw(screen, a.T("shop.hint"), x, p.Max.Y-16, colDim) + _ = color.RGBA{} +} diff --git a/internal/level/level.go b/internal/level/level.go index 5a925c3..3420778 100644 --- a/internal/level/level.go +++ b/internal/level/level.go @@ -172,9 +172,20 @@ type SignText struct { Text Text `json:"text"` } +// Rewards är vad en klarad bana ger utöver poäng. +type Rewards struct { + Items []string `json:"items"` // item-id:n (t.ex. skins) som delas ut första gången + SkillPoints int `json:"skillPoints"` // extra skillpoäng första gången +} + // Level är en bana. type Level struct { ID string `json:"id"` + Kind string `json:"kind"` // "main" (standard) eller "side" + Order int `json:"order"` // huvudbanornas ordning på bankartan (1, 2, …); sidobanor ärver förälderns + Parent string `json:"parent"` // sidobanans huvudbana – låses upp när den är klarad + Rewards Rewards `json:"rewards"` + Shop []string `json:"shop"` // items som kiosken säljer på banan (tom = seedat urval) Name Text `json:"name"` Difficulty int `json:"difficulty"` // 1 (lätt) … 5 (svår) Destination Text `json:"destination"` // dit spelaren ska @@ -247,6 +258,22 @@ func (l *Level) Validate() error { if l.ID == "" { return fmt.Errorf("level: saknar id") } + switch l.Kind { + case "": + l.Kind = "main" + case "main", "side": + default: + return fmt.Errorf("level %s: kind måste vara main eller side", l.ID) + } + if l.Kind == "side" && l.Parent == "" { + return fmt.Errorf("level %s: sidobana utan parent", l.ID) + } + if l.Kind == "main" && l.Parent != "" { + return fmt.Errorf("level %s: huvudbana med parent", l.ID) + } + if l.Order < 0 { + return fmt.Errorf("level %s: negativ order", l.ID) + } if len(l.Grid) == 0 { return fmt.Errorf("level %s: tomt rutnät", l.ID) } @@ -534,6 +561,26 @@ func LoadFile(path string) (*Level, error) { return Parse(b) } +// IsSide rapporterar om banan är en sidobana. +func (l *Level) IsSide() bool { return l.Kind == "side" } + +// LoadAll läser alla banor i en mapp, sorterade på id. +func LoadAll(dir string) ([]*Level, error) { + ids, err := List(dir) + if err != nil { + return nil, err + } + var out []*Level + for _, id := range ids { + l, err := LoadFile(filepath.Join(dir, id+".json")) + if err != nil { + return nil, err + } + out = append(out, l) + } + return out, nil +} + // List returnerar id:n (filnamn utan .json) för alla banor i en mapp, sorterade. func List(dir string) ([]string, error) { files, err := filepath.Glob(filepath.Join(dir, "*.json")) diff --git a/internal/level/level_test.go b/internal/level/level_test.go index 436c92c..cc1261e 100644 --- a/internal/level/level_test.go +++ b/internal/level/level_test.go @@ -150,6 +150,9 @@ func TestAllShippedLevels(t *testing.T) { if l.Party > 1 && (len(l.Find(Toilet)) == 0 || len(l.Find(Kiosk)) == 0) { t.Errorf("banor med sällskap behöver minst en toalett (W) och en kiosk (K)") } + if l.Kind == "main" && l.Order == 0 { + t.Errorf("huvudbanor behöver order för bankartan") + } for _, sg := range l.Signs { if sg.Text["sv"] == "" || sg.Text["en"] == "" { t.Errorf("skylt (%d,%d) saknar sv/en-text", sg.X, sg.Y) @@ -384,3 +387,46 @@ func TestTrackTile(t *testing.T) { t.Fatal("räls till höger om perrongen är vertikal") } } + +func TestKindOrderParentAndLoadAll(t *testing.T) { + l := mustParse(t, sample) + if l.Kind != "main" || l.IsSide() { + t.Fatal("standard är main") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side"`, 1))); err == nil { + t.Fatal("sidobana utan parent ska ge fel") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "parent": "x"`, 1))); err == nil { + t.Fatal("huvudbana med parent ska ge fel") + } + if _, err := Parse([]byte(strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "bus"`, 1))); err == nil { + t.Fatal("okänd kind ska ge fel") + } + side := mustParse(t, strings.Replace(sample, `"difficulty": 1`, `"difficulty": 1, "kind": "side", "parent": "001", "rewards": {"items": ["skin_kimono"], "skillPoints": 1}`, 1)) + if !side.IsSide() || side.Parent != "001" || side.Rewards.SkillPoints != 1 || len(side.Rewards.Items) != 1 { + t.Fatal("sidobanans fält ska läsas") + } + dir, _ := res.Path("data", "levels") + all, err := LoadAll(dir) + if err != nil || len(all) < 4 { + t.Fatalf("LoadAll: %d banor, %v", len(all), err) + } + sides := 0 + for _, l := range all { + if l.IsSide() { + sides++ + found := false + for _, m := range all { + if m.ID == l.Parent && !m.IsSide() { + found = true + } + } + if !found { + t.Errorf("%s: föräldern %q finns inte bland huvudbanorna", l.ID, l.Parent) + } + } + } + if sides == 0 { + t.Fatal("det ska finnas minst en sidobana") + } +} diff --git a/internal/minigame/crowd.go b/internal/minigame/crowd.go index 88e65e2..e407f43 100644 --- a/internal/minigame/crowd.go +++ b/internal/minigame/crowd.go @@ -17,11 +17,13 @@ type Crowd struct { const crowdZoneWidth = 0.22 -// NewCrowd seedar zonens läge och markörens fart. -func NewCrowd(seed rng.Seed, tries int) *Crowd { +// NewCrowd seedar zonens läge och markörens fart. luck (1 = normalt) gör +// zonen bredare eller smalare, inom rimliga gränser. +func NewCrowd(seed rng.Seed, tries int, luck float64) *Crowd { r := newRand(seed, "crowd") - lo := r.between(0.1, 0.9-crowdZoneWidth) - return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + crowdZoneWidth, MaxTries: max(1, tries)} + width := min(0.6, max(0.1, crowdZoneWidth*max(0.5, min(2, luck)))) + lo := r.between(0.1, 0.9-width) + return &Crowd{dir: 1, Speed: r.between(0.9, 1.5), ZoneLo: lo, ZoneHi: lo + width, MaxTries: max(1, tries)} } // InZone rapporterar om markören är i zonen just nu. diff --git a/internal/minigame/minigame_test.go b/internal/minigame/minigame_test.go index e167d5c..c660266 100644 --- a/internal/minigame/minigame_test.go +++ b/internal/minigame/minigame_test.go @@ -114,7 +114,7 @@ func TestTicketBoardSeededAndAffordable(t *testing.T) { } func TestCrowdZoneAndAttempts(t *testing.T) { - g := NewCrowd(rng.FromInt(7), 2) + g := NewCrowd(rng.FromInt(7), 2, 1) if g.ZoneLo < 0.1 || g.ZoneHi > 0.9 || math.Abs((g.ZoneHi-g.ZoneLo)-crowdZoneWidth) > 1e-9 { t.Fatalf("zon %.2f–%.2f", g.ZoneLo, g.ZoneHi) } @@ -135,7 +135,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) { t.Fatal("träff i zonen ska lyckas") } // Två bom = tåget går. - f := NewCrowd(rng.FromInt(7), 2) + f := NewCrowd(rng.FromInt(7), 2, 1) for n := 0; n < 2; n++ { for f.InZone() { f.Update(Input{}, frame) @@ -146,7 +146,7 @@ func TestCrowdZoneAndAttempts(t *testing.T) { t.Fatal("två bom ska misslyckas") } // Markören studsar och håller sig i 0..1. - b := NewCrowd(rng.FromInt(8), 1) + b := NewCrowd(rng.FromInt(8), 1, 1) for i := 0; i < 600; i++ { b.Update(Input{}, frame) if b.Pos < 0 || b.Pos > 1 { @@ -293,3 +293,16 @@ func TestSeatSelectionPreferences(t *testing.T) { t.Fatal("avbrutet sittplatsval = inget köp") } } + +func TestCrowdLuckWidensZone(t *testing.T) { + normal := NewCrowd(rng.FromInt(3), 2, 1) + lucky := NewCrowd(rng.FromInt(3), 2, 1.5) + unlucky := NewCrowd(rng.FromInt(3), 2, 0.5) + if lucky.ZoneHi-lucky.ZoneLo <= normal.ZoneHi-normal.ZoneLo || unlucky.ZoneHi-unlucky.ZoneLo >= normal.ZoneHi-normal.ZoneLo { + t.Fatal("tur ska ge bredare zon, otur smalare") + } + huge := NewCrowd(rng.FromInt(3), 2, 100) + if huge.ZoneHi-huge.ZoneLo > 0.6+1e-9 || huge.ZoneHi > 0.9+1e-9 { + t.Fatal("zonen klampas") + } +} diff --git a/internal/progress/catalog.go b/internal/progress/catalog.go new file mode 100644 index 0000000..d5839a4 --- /dev/null +++ b/internal/progress/catalog.go @@ -0,0 +1,168 @@ +// Package progress är spelarens progression: XP och nivåer, skill tree, +// items med buffar, klarade banor och upplåsning av bankartan. Ren Go; +// katalogerna (skills.json, items.json) läses från resources/data. +package progress + +import ( + "encoding/json" + "fmt" + "sort" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// Effect är vad en skill eller ett item gör. Allt är valfritt. +type Effect struct { + Stats map[string]float64 `json:"stats"` // speed/thirst/focus/toilet/luck → multiplikator + Kana bool `json:"kana"` // kan läsa kana + FogRadius int `json:"fogRadius"` // extra siktsteg i gångarna + Landmarks bool `json:"landmarks"` // toaletter/kiosker/automater syns alltid på minikartan + FreeGates bool `json:"freeGates"` // spärrarna kostar inget (genväg) + RevealLevel string `json:"revealLevel"` // hela minikartan känd på banan + Battery bool `json:"battery"` // telefonen laddar inte ur +} + +// Skill är en nod i språk-/skillträdet. +type Skill struct { + ID string `json:"id"` + Branch string `json:"branch"` + Name level.Text `json:"name"` + Desc level.Text `json:"desc"` + Cost int `json:"cost"` + Requires []string `json:"requires"` + Effect Effect `json:"effect"` +} + +// Item är kläder, masker, hattar, prylar och skins. +type Item struct { + ID string `json:"id"` + Kind string `json:"kind"` + Slot string `json:"slot"` + Name level.Text `json:"name"` + Price int `json:"price"` // 0 = bara belöning + Effect Effect `json:"effect"` + Tint []float64 `json:"tint"` // färgton på spelaren (r, g, b) när det bärs +} + +// Slots är kroppsplatserna; ett item per plats bärs. +var Slots = []string{"head", "face", "body", "feet", "bag"} + +var statIndex = map[string]int{"speed": sim.StatSpeed, "thirst": sim.StatThirst, "focus": sim.StatFocus, "toilet": sim.StatToilet, "luck": sim.StatLuck} + +// Catalog är alla skills och items. +type Catalog struct { + Skills []Skill + Items []Item + skills map[string]*Skill + items map[string]*Item +} + +// LoadCatalog läser och validerar skills.json och items.json. +func LoadCatalog(skillsJSON, itemsJSON []byte) (*Catalog, error) { + var sk struct { + Skills []Skill `json:"skills"` + } + if err := json.Unmarshal(skillsJSON, &sk); err != nil { + return nil, fmt.Errorf("skills.json: %w", err) + } + var it struct { + Items []Item `json:"items"` + } + if err := json.Unmarshal(itemsJSON, &it); err != nil { + return nil, fmt.Errorf("items.json: %w", err) + } + c := &Catalog{Skills: sk.Skills, Items: it.Items, skills: map[string]*Skill{}, items: map[string]*Item{}} + for i := range c.Skills { + s := &c.Skills[i] + if s.ID == "" || s.Cost < 1 || s.Name["sv"] == "" || s.Name["en"] == "" { + return nil, fmt.Errorf("skill %q: saknar id/cost/namn", s.ID) + } + if _, dup := c.skills[s.ID]; dup { + return nil, fmt.Errorf("skill %q: dubblett", s.ID) + } + if err := s.Effect.validate(); err != nil { + return nil, fmt.Errorf("skill %q: %w", s.ID, err) + } + c.skills[s.ID] = s + } + for _, s := range c.Skills { + for _, r := range s.Requires { + if _, ok := c.skills[r]; !ok { + return nil, fmt.Errorf("skill %q kräver okänd skill %q", s.ID, r) + } + } + } + for i := range c.Items { + it := &c.Items[i] + if it.ID == "" || it.Name["sv"] == "" || it.Name["en"] == "" { + return nil, fmt.Errorf("item %q: saknar id/namn", it.ID) + } + if _, dup := c.items[it.ID]; dup { + return nil, fmt.Errorf("item %q: dubblett", it.ID) + } + if !validSlot(it.Slot) { + return nil, fmt.Errorf("item %q: okänd slot %q", it.ID, it.Slot) + } + if it.Price < 0 || (len(it.Tint) != 0 && len(it.Tint) != 3) { + return nil, fmt.Errorf("item %q: pris/tint", it.ID) + } + if err := it.Effect.validate(); err != nil { + return nil, fmt.Errorf("item %q: %w", it.ID, err) + } + c.items[it.ID] = it + } + return c, nil +} + +func (e Effect) validate() error { + for k, v := range e.Stats { + if _, ok := statIndex[k]; !ok { + return fmt.Errorf("okänd stat %q", k) + } + if v <= 0 || v > 3 { + return fmt.Errorf("stat %s=%v utanför 0..3", k, v) + } + } + return nil +} + +func validSlot(s string) bool { + for _, x := range Slots { + if x == s { + return true + } + } + return false +} + +// Skill slår upp en skill (nil om okänd). +func (c *Catalog) Skill(id string) *Skill { return c.skills[id] } + +// Item slår upp ett item (nil om okänt). +func (c *Catalog) Item(id string) *Item { return c.items[id] } + +// Purchasable är alla items med pris, billigast först. +func (c *Catalog) Purchasable() []Item { + var out []Item + for _, it := range c.Items { + if it.Price > 0 { + out = append(out, it) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Price < out[j].Price }) + return out +} + +// ThirdCost är vad den billigaste tredjedelen av alla köpbara items kostar +// tillsammans – designregeln säger att det ska vara mer än en omgångs +// startkassa, så att man knappt får tag på 33 % under en omgång. +func (c *Catalog) ThirdCost() int { + p := c.Purchasable() + n := (len(p) + 2) / 3 + sum := 0 + for _, it := range p[:n] { + sum += it.Price + } + return sum +} diff --git a/internal/progress/progress.go b/internal/progress/progress.go new file mode 100644 index 0000000..aec4e07 --- /dev/null +++ b/internal/progress/progress.go @@ -0,0 +1,262 @@ +package progress + +import ( + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// XP: nivå n+1 nås när man samlat 150·n(n+1)/2 XP (150, 450, 900, …). +// Varje level-up ger en skillpoäng. +const XPPerLevelStep = 150 + +// LevelResult är bästa resultatet på en bana. +type LevelResult struct { + Cleared bool `json:"cleared"` + Stars int `json:"stars"` + Score int `json:"score"` + Attempts int `json:"attempts"` +} + +// Progress är det som växer mellan banor. +type Progress struct { + XP int `json:"xp"` + SkillPoints int `json:"skillPoints"` + Skills map[string]bool `json:"skills"` + Items map[string]bool `json:"items"` + Equipped map[string]string `json:"equipped"` // slot -> item-id + Levels map[string]*LevelResult `json:"levels"` +} + +// New börjar från noll. +func New() *Progress { + return &Progress{Skills: map[string]bool{}, Items: map[string]bool{}, Equipped: map[string]string{}, Levels: map[string]*LevelResult{}} +} + +func (p *Progress) ensure() { + if p.Skills == nil { + p.Skills = map[string]bool{} + } + if p.Items == nil { + p.Items = map[string]bool{} + } + if p.Equipped == nil { + p.Equipped = map[string]string{} + } + if p.Levels == nil { + p.Levels = map[string]*LevelResult{} + } +} + +// LevelFor är nivån (från 1) för en XP-summa. +func LevelFor(xp int) int { + n := 0 + for XPPerLevelStep*(n+1)*(n+2)/2 <= xp { + n++ + } + return n + 1 +} + +// XPToNext är hur mycket som saknas till nästa nivå. +func XPToNext(xp int) int { + n := LevelFor(xp) // nästa nivå är n+1, som kräver 150·n(n+1)/2 + return XPPerLevelStep*n*(n+1)/2 - xp +} + +// XPFor ger XP för en klarad bana. +func XPFor(score, stars int) int { return score/10 + 25*stars } + +// AddXP lägger till XP och ger en skillpoäng per level-up. Returnerar antalet level-ups. +func (p *Progress) AddXP(n int) int { + before := LevelFor(p.XP) + p.XP += max(0, n) + ups := LevelFor(p.XP) - before + p.SkillPoints += ups + return ups +} + +// Level är spelarens nivå. +func (p *Progress) Level() int { return LevelFor(p.XP) } + +// Has rapporterar om skillen är upplåst. +func (p *Progress) Has(skill string) bool { return p.Skills[skill] } + +// CanUnlock: har poängen, kraven uppfyllda och inte redan upplåst. +func (p *Progress) CanUnlock(c *Catalog, id string) bool { + s := c.Skill(id) + if s == nil || p.Has(id) || p.SkillPoints < s.Cost { + return false + } + for _, r := range s.Requires { + if !p.Has(r) { + return false + } + } + return true +} + +// Unlock låser upp en skill mot skillpoäng. +func (p *Progress) Unlock(c *Catalog, id string) bool { + if !p.CanUnlock(c, id) { + return false + } + p.ensure() + p.SkillPoints -= c.Skill(id).Cost + p.Skills[id] = true + return true +} + +// Own rapporterar om spelaren har itemet. +func (p *Progress) Own(item string) bool { return p.Items[item] } + +// Give ger ett item (belöning). Bärs direkt om platsen är ledig. Returnerar +// false om spelaren redan hade det. +func (p *Progress) Give(c *Catalog, id string) bool { + it := c.Item(id) + if it == nil || p.Own(id) { + return false + } + p.ensure() + p.Items[id] = true + if p.Equipped[it.Slot] == "" { + p.Equipped[it.Slot] = id + } + return true +} + +// Buy köper ett item kontant via pay. Returnerar false om det inte gick. +func (p *Progress) Buy(c *Catalog, id string, pay func(yen int) bool) bool { + it := c.Item(id) + if it == nil || it.Price <= 0 || p.Own(id) || !pay(it.Price) { + return false + } + return p.Give(c, id) +} + +// Equip bär ett ägt item på sin plats (ersätter det som satt där). +func (p *Progress) Equip(c *Catalog, id string) bool { + it := c.Item(id) + if it == nil || !p.Own(id) { + return false + } + p.ensure() + p.Equipped[it.Slot] = id + return true +} + +// Unequip tar av det som bärs på platsen. +func (p *Progress) Unequip(slot string) { delete(p.Equipped, slot) } + +// Worn är itemet på platsen, eller nil. +func (p *Progress) Worn(c *Catalog, slot string) *Item { + if id := p.Equipped[slot]; id != "" { + return c.Item(id) + } + return nil +} + +// Aggregate är de sammanlagda effekterna av skills och burna items. +type Aggregate struct { + Stats sim.Stats + Kana bool + FogRadius int + Landmarks bool + FreeGates bool + Battery bool + Reveal map[string]bool + Tint [3]float32 +} + +// Effects räknar ihop allt spelaren har. +func (p *Progress) Effects(c *Catalog) Aggregate { + a := Aggregate{Stats: sim.DefaultStats(), Reveal: map[string]bool{}, Tint: [3]float32{1, 1, 1}} + apply := func(e Effect) { + for k, v := range e.Stats { + a.Stats[statIndex[k]] *= v + } + a.Kana = a.Kana || e.Kana + a.FogRadius += e.FogRadius + a.Landmarks = a.Landmarks || e.Landmarks + a.FreeGates = a.FreeGates || e.FreeGates + a.Battery = a.Battery || e.Battery + if e.RevealLevel != "" { + a.Reveal[e.RevealLevel] = true + } + } + for id := range p.Skills { + if s := c.Skill(id); s != nil { + apply(s.Effect) + } + } + for _, slot := range Slots { + if it := p.Worn(c, slot); it != nil { + apply(it.Effect) + if len(it.Tint) == 3 { + for i := range a.Tint { + a.Tint[i] *= float32(it.Tint[i]) + } + } + } + } + return a +} + +// RecordLevel sparar ett resultat; bästa stjärnor/poäng behålls. Returnerar +// true första gången banan klaras. +func (p *Progress) RecordLevel(id string, won bool, stars, score int) (first bool) { + p.ensure() + r := p.Levels[id] + if r == nil { + r = &LevelResult{} + p.Levels[id] = r + } + r.Attempts++ + if !won { + return false + } + first = !r.Cleared + r.Cleared = true + r.Stars = max(r.Stars, stars) + r.Score = max(r.Score, score) + return first +} + +// Cleared rapporterar om banan är klarad. +func (p *Progress) Cleared(id string) bool { + r := p.Levels[id] + return r != nil && r.Cleared +} + +// Unlocked avgör om en bana går att spela: huvudbana 1 alltid, huvudbana n +// när huvudbana n−1 är klarad, sidobana när föräldern är klarad. +func (p *Progress) Unlocked(all []*level.Level, id string) bool { + var me *level.Level + for _, l := range all { + if l.ID == id { + me = l + } + } + if me == nil { + return false + } + if me.IsSide() { + return p.Cleared(me.Parent) + } + if me.Order <= 1 { + return true + } + for _, l := range all { + if !l.IsSide() && l.Order == me.Order-1 { + return p.Cleared(l.ID) + } + } + return false +} + +// TotalStars är summan av bästa stjärnor. +func (p *Progress) TotalStars() int { + n := 0 + for _, r := range p.Levels { + n += r.Stars + } + return n +} diff --git a/internal/progress/progress_test.go b/internal/progress/progress_test.go new file mode 100644 index 0000000..f02fbb6 --- /dev/null +++ b/internal/progress/progress_test.go @@ -0,0 +1,216 @@ +package progress + +import ( + "math" + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/res" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +func loadCatalog(t *testing.T) *Catalog { + t.Helper() + sk, err := res.ReadFile("data", "skills.json") + if err != nil { + t.Fatal(err) + } + it, err := res.ReadFile("data", "items.json") + if err != nil { + t.Fatal(err) + } + c, err := LoadCatalog(sk, it) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestShippedCatalogAndThirdRule(t *testing.T) { + c := loadCatalog(t) + if len(c.Skills) < 10 || len(c.Items) < 20 { + t.Fatalf("skills=%d items=%d", len(c.Skills), len(c.Items)) + } + // Designregeln: billigaste tredjedelen av alla köpbara items kostar mer + // än startkassan, så att man knappt får tag på 33 % under en omgång. + if c.ThirdCost() <= sim.StartCashYen { + t.Fatalf("billigaste tredjedelen kostar %d ≤ startkassan %d", c.ThirdCost(), sim.StartCashYen) + } + // Alla banor som items pekar på finns. + dir, _ := res.Path("data", "levels") + all, err := level.LoadAll(dir) + if err != nil { + t.Fatal(err) + } + ids := map[string]bool{} + for _, l := range all { + ids[l.ID] = true + for _, r := range l.Rewards.Items { + if c.Item(r) == nil { + t.Errorf("%s belönar okänt item %q", l.ID, r) + } + } + for _, s := range l.Shop { + if it := c.Item(s); it == nil || it.Price == 0 { + t.Errorf("%s säljer okänt/gratis item %q", l.ID, s) + } + } + } + for _, it := range c.Items { + if it.Effect.RevealLevel != "" && !ids[it.Effect.RevealLevel] { + t.Errorf("%s avslöjar okänd bana %q", it.ID, it.Effect.RevealLevel) + } + } +} + +func TestCatalogValidation(t *testing.T) { + bad := []struct{ name, skills, items string }{ + {"okänd stat", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"effect":{"stats":{"mana":2}}}]}`, `{"items":[]}`}, + {"okänt krav", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"},"requires":["zzz"]}]}`, `{"items":[]}`}, + {"dubblett", `{"skills":[{"id":"a","cost":1,"name":{"sv":"A","en":"A"}},{"id":"a","cost":1,"name":{"sv":"A","en":"A"}}]}`, `{"items":[]}`}, + {"okänd slot", `{"skills":[]}`, `{"items":[{"id":"x","slot":"tail","name":{"sv":"X","en":"X"},"price":1}]}`}, + {"trasig tint", `{"skills":[]}`, `{"items":[{"id":"x","slot":"head","name":{"sv":"X","en":"X"},"price":1,"tint":[1,2]}]}`}, + } + for _, b := range bad { + if _, err := LoadCatalog([]byte(b.skills), []byte(b.items)); err == nil { + t.Errorf("%s: förväntade fel", b.name) + } + } +} + +func TestXPLevelsAndSkillPoints(t *testing.T) { + cases := map[int]int{0: 1, 149: 1, 150: 2, 449: 2, 450: 3, 900: 4, 1500: 5} + for xp, lvl := range cases { + if LevelFor(xp) != lvl { + t.Errorf("LevelFor(%d)=%d, vill ha %d", xp, LevelFor(xp), lvl) + } + } + if XPToNext(0) != 150 || XPToNext(150) != 300 || XPToNext(449) != 1 { + t.Fatalf("XPToNext: %d %d %d", XPToNext(0), XPToNext(150), XPToNext(449)) + } + p := New() + if ups := p.AddXP(XPFor(941, 3)); ups != 1 || p.SkillPoints != 1 || p.Level() != 2 { + t.Fatalf("ups=%d points=%d level=%d", ups, p.SkillPoints, p.Level()) + } + if ups := p.AddXP(1000); ups != 2 || p.SkillPoints != 3 { + t.Fatalf("två nivåer på en gång: ups=%d points=%d", ups, p.SkillPoints) + } + if p.AddXP(-5) != 0 || p.XP != XPFor(941, 3)+1000 { + t.Fatal("negativ XP ignoreras") + } +} + +func TestSkillTreeRequirementsAndCost(t *testing.T) { + c := loadCatalog(t) + p := New() + if p.CanUnlock(c, "focus1") || p.Unlock(c, "focus1") { + t.Fatal("utan poäng går inget") + } + p.SkillPoints = 3 + if p.CanUnlock(c, "focus2") { + t.Fatal("focus2 kräver focus1") + } + if !p.Unlock(c, "focus1") || p.SkillPoints != 2 || !p.Has("focus1") { + t.Fatal("focus1 kostar 1") + } + if !p.Unlock(c, "focus2") || p.SkillPoints != 0 { + t.Fatal("focus2 kostar 2 och kräver focus1") + } + if p.Unlock(c, "focus1") { + t.Fatal("inte två gånger") + } + eff := p.Effects(c) + if math.Abs(eff.Stats[sim.StatFocus]-1.25*1.25) > 1e-9 || eff.Kana { + t.Fatalf("effekter: %+v", eff) + } + p.SkillPoints = 5 + p.Unlock(c, "kana") + p.Unlock(c, "scout") + p.Unlock(c, "local") + eff = p.Effects(c) + if !eff.Kana || eff.FogRadius != 1 || !eff.Landmarks { + t.Fatalf("flaggor: %+v", eff) + } +} + +func TestItemsBuyEquipAndEffects(t *testing.T) { + c := loadCatalog(t) + p := New() + cash := 10000 + pay := func(yen int) bool { + if cash < yen { + return false + } + cash -= yen + return true + } + if !p.Buy(c, "sneakers", pay) || !p.Own("sneakers") || p.Equipped["feet"] != "sneakers" || cash != 10000-3500 { + t.Fatalf("köp: own=%v equipped=%v cash=%d", p.Own("sneakers"), p.Equipped, cash) + } + if p.Buy(c, "sneakers", pay) || p.Buy(c, "skin_kimono", pay) || p.Buy(c, "rail_pass", pay) { + t.Fatal("inte igen, inte gratisitems, inte utan råd (8000 > 6500)") + } + if !p.Buy(c, "geta", pay) || p.Equipped["feet"] != "sneakers" { + t.Fatal("andra skor köps men byter inte automatiskt") + } + if !p.Equip(c, "geta") || p.Equipped["feet"] != "geta" { + t.Fatal("Equip byter") + } + eff := p.Effects(c) + if math.Abs(eff.Stats[sim.StatSpeed]-0.9) > 1e-9 || math.Abs(eff.Stats[sim.StatLuck]-1.2) > 1e-9 { + t.Fatalf("geta: %+v", eff.Stats) + } + p.Unequip("feet") + if p.Effects(c).Stats[sim.StatSpeed] != 1 || p.Worn(c, "feet") != nil { + t.Fatal("Unequip") + } + if !p.Give(c, "skin_kimono") || p.Give(c, "skin_kimono") || p.Equipped["body"] != "skin_kimono" { + t.Fatal("belöning ges en gång och bärs om platsen är ledig") + } + eff = p.Effects(c) + if eff.Tint == [3]float32{1, 1, 1} { + t.Fatal("skinnet ska tona spelaren") + } + p.Give(c, "rail_pass") + p.Give(c, "map_shibuya") + p.Equip(c, "map_shibuya") // bara en pryl i väskan åt gången + eff = p.Effects(c) + if eff.FreeGates || !eff.Reveal["003-shibuya"] { + t.Fatalf("väskan: %+v", eff) + } + p.Equip(c, "rail_pass") + if !p.Effects(c).FreeGates { + t.Fatal("JR Pass i väskan ger gratis spärrar") + } +} + +func TestLevelRecordsAndUnlocking(t *testing.T) { + dir, _ := res.Path("data", "levels") + all, err := level.LoadAll(dir) + if err != nil { + t.Fatal(err) + } + p := New() + if !p.Unlocked(all, "001-flygplatsen") || p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") || p.Unlocked(all, "003-shibuya") { + t.Fatal("bara första huvudbanan är öppen från start") + } + if p.RecordLevel("001-flygplatsen", false, 0, 0) || p.Cleared("001-flygplatsen") || p.Levels["001-flygplatsen"].Attempts != 1 { + t.Fatal("förlust räknas som försök") + } + if !p.RecordLevel("001-flygplatsen", true, 2, 700) || !p.Unlocked(all, "002-shinjuku") || p.Unlocked(all, "002b-kabukicho") { + t.Fatal("första klaring öppnar nästa huvudbana men inte sidobanan under 002") + } + if p.RecordLevel("001-flygplatsen", true, 3, 500) { + t.Fatal("andra klaring är inte första") + } + if r := p.Levels["001-flygplatsen"]; r.Stars != 3 || r.Score != 700 || r.Attempts != 3 { + t.Fatalf("bästa behålls: %+v", r) + } + p.RecordLevel("002-shinjuku", true, 1, 300) + if !p.Unlocked(all, "002b-kabukicho") || !p.Unlocked(all, "003-shibuya") || p.TotalStars() != 4 { + t.Fatalf("efter 002: sidobana och 003 öppna, stjärnor=%d", p.TotalStars()) + } + if p.Unlocked(all, "finns-inte") { + t.Fatal("okänd bana") + } +} diff --git a/internal/save/save.go b/internal/save/save.go new file mode 100644 index 0000000..14bfea1 --- /dev/null +++ b/internal/save/save.go @@ -0,0 +1,122 @@ +// Package save läser och skriver sparfilen: kunskap, plånbok, debuffar, +// progression och den pågående omgångens bana + seed. JSON med +// versionsfält så att gamla filer kan migreras. +package save + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/lang" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/progress" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +// Version är sparfilens formatversion. +const Version = 1 + +// AppDir är mappnamnet under användarens konfigmapp. +const AppDir = "metro-puzzle-jp" + +// RunRef pekar ut en pågående omgång så att den kan återupptas med samma +// slump (minispel, händelser, läsning). +type RunRef struct { + LevelID string `json:"levelId"` + Seed [2]uint64 `json:"seed"` + Started time.Time `json:"started"` +} + +// State är allt som sparas. +type State struct { + Version int `json:"version"` + SavedAt time.Time `json:"savedAt"` + Know *lang.Knowledge `json:"knowledge"` + Wallet *sim.Wallet `json:"wallet"` + Profile *sim.Profile `json:"profile"` + Progress *progress.Progress `json:"progress"` + Current *RunRef `json:"current,omitempty"` +} + +// New är ett nytt spel. +func New() *State { + return &State{Version: Version, Know: lang.NewKnowledge(), Wallet: sim.NewWallet(), Profile: &sim.Profile{}, Progress: progress.New()} +} + +// DefaultPath är sparfilens plats: ~/.config/metro-puzzle-jp/save.json på +// Linux, %AppData%\metro-puzzle-jp\save.json på Windows. +func DefaultPath() (string, error) { + dir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(dir, AppDir, "save.json"), nil +} + +// Load läser sparfilen. Saknas den returneras ett nytt spel (och exists=false). +func Load(path string) (st *State, exists bool, err error) { + b, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return New(), false, nil + } + if err != nil { + return nil, false, err + } + var s State + if err := json.Unmarshal(b, &s); err != nil { + return nil, true, fmt.Errorf("sparfilen %s är trasig: %w", path, err) + } + if err := migrate(&s); err != nil { + return nil, true, err + } + return &s, true, nil +} + +// migrate lyfter äldre versioner till den aktuella och fyller i luckor. +func migrate(s *State) error { + if s.Version > Version { + return fmt.Errorf("sparfilen är från en nyare version (%d > %d)", s.Version, Version) + } + // Version 0 (saknat fält) och 1 har samma form; här läggs framtida steg. + s.Version = Version + if s.Know == nil { + s.Know = lang.NewKnowledge() + } + if s.Know.Words == nil { + s.Know.Words = map[string]bool{} + } + if s.Know.Progress == nil { + s.Know.Progress = map[string]int{} + } + if s.Wallet == nil { + s.Wallet = sim.NewWallet() + } + if s.Profile == nil { + s.Profile = &sim.Profile{} + } + if s.Progress == nil { + s.Progress = progress.New() + } + return nil +} + +// Store skriver sparfilen atomiskt (tempfil + byte av namn). +func Store(path string, s *State) error { + s.Version = Version + s.SavedAt = time.Now() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o644); err != nil { + return err + } + return os.Rename(tmp, path) +} diff --git a/internal/save/save_test.go b/internal/save/save_test.go new file mode 100644 index 0000000..ad94e8f --- /dev/null +++ b/internal/save/save_test.go @@ -0,0 +1,78 @@ +package save + +import ( + "os" + "path/filepath" + "testing" + + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/sim" +) + +func TestRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "save.json") + st, exists, err := Load(path) + if err != nil || exists || st.Version != Version || st.Progress == nil || st.Know.Count() != 0 { + t.Fatalf("saknad fil ska ge nytt spel: exists=%v err=%v", exists, err) + } + st.Know.Learn("改札") + st.Know.Points = 3 + st.Wallet.Card = 1234 + st.Wallet.Phone = true + st.Profile.Add(sim.Tired, 2) + st.Progress.AddXP(500) + st.Progress.Skills["kana"] = true + st.Progress.Items["geta"] = true + st.Progress.Equipped["feet"] = "geta" + st.Progress.RecordLevel("001-flygplatsen", true, 3, 941) + st.Current = &RunRef{LevelID: "002-shinjuku", Seed: [2]uint64{7, 99}} + if err := Store(path, st); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path + ".tmp"); err == nil { + t.Fatal("tempfilen ska vara borta") + } + got, exists, err := Load(path) + if err != nil || !exists { + t.Fatalf("exists=%v err=%v", exists, err) + } + if !got.Know.Knows("改札") || got.Know.Points != 3 || got.Wallet.Card != 1234 || !got.Wallet.Phone { + t.Fatal("kunskap/plånbok") + } + if !got.Profile.Has(sim.Tired) || got.Progress.XP != 500 || got.Progress.Level() != 3 || !got.Progress.Has("kana") || got.Progress.Equipped["feet"] != "geta" { + t.Fatalf("profil/progression: %+v", got.Progress) + } + if !got.Progress.Cleared("001-flygplatsen") || got.Progress.Levels["001-flygplatsen"].Score != 941 { + t.Fatal("banresultat") + } + if got.Current == nil || got.Current.LevelID != "002-shinjuku" || got.Current.Seed != [2]uint64{7, 99} { + t.Fatalf("pågående omgång: %+v", got.Current) + } + if got.SavedAt.IsZero() { + t.Fatal("SavedAt") + } +} + +func TestCorruptAndFutureVersions(t *testing.T) { + path := filepath.Join(t.TempDir(), "save.json") + os.WriteFile(path, []byte("{nope"), 0o644) + if _, _, err := Load(path); err == nil { + t.Fatal("trasig fil ska ge fel") + } + os.WriteFile(path, []byte(`{"version": 99}`), 0o644) + if _, _, err := Load(path); err == nil { + t.Fatal("nyare version ska ge fel") + } + // Version 0 utan fält migreras till ett komplett tillstånd. + os.WriteFile(path, []byte(`{"knowledge": {"words": {"改札": true}}}`), 0o644) + st, exists, err := Load(path) + if err != nil || !exists || st.Version != Version || !st.Know.Knows("改札") || st.Wallet == nil || st.Progress == nil || st.Profile == nil { + t.Fatalf("migrering: %+v err=%v", st, err) + } +} + +func TestDefaultPath(t *testing.T) { + p, err := DefaultPath() + if err != nil || filepath.Base(p) != "save.json" || filepath.Base(filepath.Dir(p)) != AppDir { + t.Fatalf("path=%q err=%v", p, err) + } +} diff --git a/internal/sim/fog.go b/internal/sim/fog.go index fdb7fe4..a0a7588 100644 --- a/internal/sim/fog.go +++ b/internal/sim/fog.go @@ -9,10 +9,11 @@ const FogRadius = 3 // Fog håller reda på vilka rutor spelaren har sett i den här omgången. // Minikartan ritar bara sedda rutor plus riktmärken som alltid syns. type Fog struct { - w, h int - seen []bool - Radius int - count int + w, h int + seen []bool + Radius int + count int + ExtraLandmarks bool // toaletter, kiosker och automater syns alltid (skillen Lokalkännedom) } // NewFog skapar en tom dimma för banan. @@ -83,7 +84,25 @@ func Landmark(l *level.Level, p level.Point) bool { // Visible rapporterar om rutan ska ritas på minikartan. func (f *Fog) Visible(l *level.Level, p level.Point) bool { - return f.Seen(p) || Landmark(l, p) + if f.Seen(p) || Landmark(l, p) { + return true + } + if f.ExtraLandmarks { + switch l.At(p.X, p.Y) { + case level.Toilet, level.Kiosk, level.TicketMachine: + return true + } + } + return false +} + +// RevealAll avslöjar hela banan (kartan köpt som item). +func (f *Fog) RevealAll(l *level.Level) { + for y := 0; y < l.Height(); y++ { + for x := 0; x < l.Width(); x++ { + f.mark(level.Point{X: x, Y: y}) + } + } } // Count är antalet sedda rutor (inkl. väggar). diff --git a/internal/sim/run.go b/internal/sim/run.go index 1ed302f..5966e67 100644 --- a/internal/sim/run.go +++ b/internal/sim/run.go @@ -84,6 +84,7 @@ type Run struct { spentStart int // plånbokens Spent vid banstart Score ScoreBreakdown NewDebuffs []Debuff // debuffar banan gav (appliceras på profilen efteråt) + opts RunOptions } // ScoreBreakdown är poängen för en vunnen bana. @@ -163,6 +164,14 @@ type RunOptions struct { Wallet *Wallet // nil = startkassa Debuffs []Debuff // aktiva debuffar från profilen Seed rng.Seed // omgångens seed (sparas med spelet) + + // Från skills och items (progression): + Extra Stats // multipliceras in i statsen (nollvärde = inga) + FogRadius int // extra siktsteg + Landmarks bool // toaletter/kiosker/automater alltid på minikartan + FreeGates bool // spärrarna kostar inget och kräver inget + RevealAll bool // hela banan känd från start + InfiniteBattery bool // telefonen laddar inte ur } // DefaultSpeed är spelarens grundfart i rutor per sekund. @@ -215,11 +224,21 @@ func NewRun(l *level.Level, o RunOptions) *Run { Party: NewParty(l.Party, l.SpawnPoint(), o.Seed.Derive("party")), baseSpeed: o.Speed, NoticeAt: -1, - Fog: NewFog(l, FogRadius), Stats: CombinedStats(o.Debuffs), spentStart: o.Wallet.Spent, + opts: o, } + if o.Extra != (Stats{}) { + for i := range r.Stats { + r.Stats[i] *= o.Extra[i] + } + } + r.Fog = NewFog(l, FogRadius+o.FogRadius) + r.Fog.ExtraLandmarks = o.Landmarks r.Party.Rates = Rates{Thirst: r.Stats[StatThirst], Toilet: r.Stats[StatToilet]} + if o.RevealAll { + r.Fog.RevealAll(l) + } r.Fog.Reveal(l, l.SpawnPoint()) if r.parSec <= 0 { n := len(l.Path(l.SpawnPoint(), l.CorrectPlatform(), l.CanEnter)) @@ -259,6 +278,9 @@ func (r *Run) CanEnter(p level.Point) bool { } func (r *Run) canPassGate() bool { + if r.opts.FreeGates { + return true // JR Pass: genväg genom alla spärrar + } if r.Level.TicketRequired { if r.Wallet.Ticket == "" { return false @@ -306,7 +328,9 @@ func (r *Run) Step(dt float64, want Dir, board bool) { return // minispel pågår: använd StepMini } r.Time += dt - r.Wallet.Drain(dt) + if !r.opts.InfiniteBattery { + r.Wallet.Drain(dt) + } before := r.Player.Tile wasGate := r.inGate() r.Player.Speed = r.baseSpeed * r.Party.SpeedFactor() * r.Stats[StatSpeed] @@ -332,7 +356,7 @@ func (r *Run) Step(dt float64, want Dir, board bool) { r.checkLimits() if r.Player.Tile != before && r.inGate() && !wasGate { r.GatePasses++ - if !r.Level.TicketRequired { + if !r.Level.TicketRequired && !r.opts.FreeGates { r.Wallet.PayCard(r.Level.FareYen) if r.Level.IndividualCards { for _, m := range r.Party.Followers() { @@ -427,7 +451,7 @@ func (r *Run) tryBoard(tr *level.Train) { } r.PendingTrain = tr if tr.Crowded { - r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries), MiniCrowd + r.Mini, r.MiniKind = minigame.NewCrowd(r.miniSeed(), CrowdTries, r.Stats[StatLuck]), MiniCrowd return } r.startRide() @@ -449,7 +473,9 @@ func (r *Run) StepMini(dt float64, in minigame.Input) { return } r.Time += dt - r.Wallet.Drain(dt) + if !r.opts.InfiniteBattery { + r.Wallet.Drain(dt) + } r.Party.Advance(dt, r.Time) r.Mini.Update(in, dt) if !r.Mini.Done() { diff --git a/internal/sim/run_test.go b/internal/sim/run_test.go index ba1f2da..b0572c1 100644 --- a/internal/sim/run_test.go +++ b/internal/sim/run_test.go @@ -954,3 +954,50 @@ func TestNearTrackAndPhases(t *testing.T) { t.Fatal("tre rutor bort är utom räckhåll") } } + +func TestRunOptionsFromProgression(t *testing.T) { + l := loadLevel(t, "001-flygplatsen") + extra := DefaultStats() + extra[StatSpeed] = 1.2 + r := NewRun(l, RunOptions{Seed: rng.FromInt(1), Extra: extra, FogRadius: 2, Landmarks: true, FreeGates: true, RevealAll: true, InfiniteBattery: true}) + r.Party.nextEventAt = 1e9 + r.Wallet.Card, r.Wallet.Phone, r.Wallet.Battery = 0, true, 50 + r.Step(frame, Right, false) + if math.Abs(r.Player.Speed-DefaultSpeed*1.2*PhoneSpeedFactor) > 1e-9 { + t.Fatalf("extra fart: %.2f", r.Player.Speed) + } + if r.Wallet.Battery != 50 { + t.Fatal("powerbank: batteriet laddar inte ur") + } + if r.Fog.Radius != FogRadius+2 || !r.Fog.ExtraLandmarks || r.Fog.Explored(l) != 1 { + t.Fatalf("dimma: radius=%d landmarks=%v explored=%.2f", r.Fog.Radius, r.Fog.ExtraLandmarks, r.Fog.Explored(l)) + } + gate := l.Find(level.Gate)[0] + if !r.CanEnter(gate) { + t.Fatal("JR Pass: spärren släpper igenom utan saldo") + } + for r.Player.Moving() { + r.Step(frame, None, false) + } + path := l.Path(r.Player.Tile, gate, r.CanEnter) + for i, p := range path { + if l.At(p.X, p.Y) == level.Gate { + path = path[:i+1] + break + } + } + walk(t, r, path) + if r.Wallet.Spent != 0 || r.GatePasses != 1 { + t.Fatalf("gratis spärr: spent=%d passes=%d", r.Wallet.Spent, r.GatePasses) + } + // Utan extras: standard. + plain := NewRun(l, RunOptions{Seed: rng.FromInt(1)}) + if plain.Stats != DefaultStats() || plain.Fog.Radius != FogRadius || plain.Fog.ExtraLandmarks { + t.Fatal("standardvärden utan progression") + } + // Lokalkännedom: kiosken syns på minikartan utan att vara sedd. + k := l.Find(level.Kiosk)[0] + if plain.Fog.Visible(l, k) || !r.Fog.Visible(l, k) { + t.Fatal("landmärken från skillen") + } +} diff --git a/resources/data/items.json b/resources/data/items.json new file mode 100644 index 0000000..6c5d48e --- /dev/null +++ b/resources/data/items.json @@ -0,0 +1,432 @@ +{ + "_comment": "Items: kläder, masker, hattar, prylar. slot = head/face/body/feet/bag (ett per plats bärs). price = kontantpris i kiosken (0 = bara belöning). effect som skills + freeGates (spärrar gratis), revealLevel (minikartan avslöjad på en bana), battery (telefonen laddar inte ur). tint = färgton på spelaren när det bärs.", + "items": [ + { + "id": "cap_red", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Röd keps", + "en": "Red cap" + }, + "price": 800, + "effect": { + "stats": { + "focus": 1.05 + } + } + }, + { + "id": "sun_hat", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Solhatt", + "en": "Sun hat" + }, + "price": 1200, + "effect": { + "stats": { + "thirst": 0.9 + } + }, + "tint": [ + 1, + 0.95, + 0.7 + ] + }, + { + "id": "beanie", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Mössa", + "en": "Beanie" + }, + "price": 900, + "effect": { + "stats": { + "toilet": 1.1, + "focus": 1.1 + } + } + }, + { + "id": "conductor_cap", + "kind": "hat", + "slot": "head", + "name": { + "sv": "Konduktörsmössa", + "en": "Conductor cap" + }, + "price": 3000, + "effect": { + "stats": { + "luck": 1.15 + } + }, + "tint": [ + 0.8, + 0.85, + 1 + ] + }, + { + "id": "mask_surgical", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Munskydd", + "en": "Surgical mask" + }, + "price": 300, + "effect": { + "stats": { + "toilet": 0.95 + } + } + }, + { + "id": "mask_fox", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Rävmask", + "en": "Fox mask" + }, + "price": 1500, + "effect": { + "stats": { + "luck": 1.1 + } + }, + "tint": [ + 1, + 0.85, + 0.7 + ] + }, + { + "id": "mask_tengu", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Tengumask", + "en": "Tengu mask" + }, + "price": 2200, + "effect": { + "stats": { + "focus": 0.9, + "speed": 1.08 + } + }, + "tint": [ + 1, + 0.7, + 0.7 + ] + }, + { + "id": "sunglasses", + "kind": "mask", + "slot": "face", + "name": { + "sv": "Solglasögon", + "en": "Sunglasses" + }, + "price": 1100, + "effect": { + "stats": { + "focus": 0.95, + "luck": 1.05 + } + } + }, + { + "id": "happi", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Happi-jacka", + "en": "Happi coat" + }, + "price": 2500, + "effect": { + "stats": { + "speed": 1.05 + } + }, + "tint": [ + 0.7, + 0.8, + 1 + ] + }, + { + "id": "yukata", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Yukata", + "en": "Yukata" + }, + "price": 4000, + "effect": { + "stats": { + "speed": 0.95, + "focus": 1.15 + } + }, + "tint": [ + 0.8, + 0.9, + 1 + ] + }, + { + "id": "rain_poncho", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Regnponcho", + "en": "Rain poncho" + }, + "price": 700, + "effect": { + "stats": { + "thirst": 1.05, + "toilet": 0.95 + } + }, + "tint": [ + 0.8, + 1, + 0.8 + ] + }, + { + "id": "suit", + "kind": "clothes", + "slot": "body", + "name": { + "sv": "Kostym", + "en": "Suit" + }, + "price": 5500, + "effect": { + "stats": { + "luck": 1.2, + "thirst": 1.1 + } + }, + "tint": [ + 0.75, + 0.75, + 0.8 + ] + }, + { + "id": "sneakers", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Sneakers", + "en": "Sneakers" + }, + "price": 3500, + "effect": { + "stats": { + "speed": 1.1 + } + } + }, + { + "id": "geta", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Geta", + "en": "Geta" + }, + "price": 1800, + "effect": { + "stats": { + "speed": 0.9, + "luck": 1.2 + } + } + }, + { + "id": "hiking_boots", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Vandringskängor", + "en": "Hiking boots" + }, + "price": 4200, + "effect": { + "stats": { + "speed": 1.05, + "toilet": 0.9 + } + } + }, + { + "id": "flipflops", + "kind": "shoes", + "slot": "feet", + "name": { + "sv": "Badtofflor", + "en": "Flip-flops" + }, + "price": 400, + "effect": { + "stats": { + "speed": 0.92, + "thirst": 0.9 + } + } + }, + { + "id": "powerbank", + "kind": "gadget", + "slot": "bag", + "name": { + "sv": "Powerbank", + "en": "Power bank" + }, + "price": 2000, + "effect": { + "battery": true + } + }, + { + "id": "water_bottle", + "kind": "gadget", + "slot": "bag", + "name": { + "sv": "Vattenflaska", + "en": "Water bottle" + }, + "price": 600, + "effect": { + "stats": { + "thirst": 0.8 + } + } + }, + { + "id": "onigiri_box", + "kind": "gadget", + "slot": "bag", + "name": { + "sv": "Onigiri-låda", + "en": "Onigiri box" + }, + "price": 900, + "effect": { + "stats": { + "focus": 1.05, + "toilet": 1.05 + } + } + }, + { + "id": "map_shinjuku", + "kind": "map", + "slot": "bag", + "name": { + "sv": "Karta: Shinjuku", + "en": "Map: Shinjuku" + }, + "price": 600, + "effect": { + "revealLevel": "002-shinjuku" + } + }, + { + "id": "map_shibuya", + "kind": "map", + "slot": "bag", + "name": { + "sv": "Karta: Shibuya", + "en": "Map: Shibuya" + }, + "price": 800, + "effect": { + "revealLevel": "003-shibuya" + } + }, + { + "id": "rail_pass", + "kind": "pass", + "slot": "bag", + "name": { + "sv": "JR Pass", + "en": "JR Pass" + }, + "price": 8000, + "effect": { + "freeGates": true + } + }, + { + "id": "omamori", + "kind": "charm", + "slot": "bag", + "name": { + "sv": "Omamori", + "en": "Omamori" + }, + "price": 1300, + "effect": { + "stats": { + "luck": 1.15 + } + } + }, + { + "id": "skin_kimono", + "kind": "skin", + "slot": "body", + "name": { + "sv": "Kimono (skin)", + "en": "Kimono (skin)" + }, + "price": 0, + "effect": { + "stats": { + "luck": 1.05 + } + }, + "tint": [ + 1, + 0.75, + 0.85 + ] + }, + { + "id": "skin_salaryman", + "kind": "skin", + "slot": "body", + "name": { + "sv": "Salaryman (skin)", + "en": "Salaryman (skin)" + }, + "price": 0, + "effect": { + "stats": { + "speed": 1.03 + } + }, + "tint": [ + 0.7, + 0.7, + 0.75 + ] + } + ] +} diff --git a/resources/data/levels/001-flygplatsen.json b/resources/data/levels/001-flygplatsen.json index 8be217b..a9f6647 100644 --- a/resources/data/levels/001-flygplatsen.json +++ b/resources/data/levels/001-flygplatsen.json @@ -1,5 +1,7 @@ { "id": "001-flygplatsen", + "kind": "main", + "order": 1, "name": { "sv": "Flygplatsen", "en": "The Airport", @@ -15,6 +17,11 @@ }, "timeLimitSec": 0, "fareYen": 200, + "rewards": { + "items": [ + "skin_salaryman" + ] + }, "grid": [ "####################", "#S.....#.....i.....#", diff --git a/resources/data/levels/002-shinjuku.json b/resources/data/levels/002-shinjuku.json index 1fa634d..2d575e6 100644 --- a/resources/data/levels/002-shinjuku.json +++ b/resources/data/levels/002-shinjuku.json @@ -1,12 +1,33 @@ { + "id": "002-shinjuku", + "kind": "main", + "order": 2, + "name": { + "en": "Shinjuku Station", + "ja": "新宿駅", + "sv": "Shinjuku station" + }, + "difficulty": 2, + "party": 3, + "ticketRequired": true, + "seatSelection": true, + "rideStops": 4, "destination": { "en": "Shibuya", "ja": "渋谷", "romaji": "Shibuya", "sv": "Shibuya" }, - "difficulty": 2, + "timeLimitSec": 420, + "maxMissedTrains": 2, "fareYen": 200, + "shop": [ + "cap_red", + "water_bottle", + "mask_surgical", + "map_shinjuku", + "sneakers" + ], "grid": [ "#################################", "#S#..i..#K#...........#.........#", @@ -30,47 +51,6 @@ "#pG.#...#.....................#P#", "#################################" ], - "id": "002-shinjuku", - "maxMissedTrains": 2, - "name": { - "en": "Shinjuku Station", - "ja": "新宿駅", - "sv": "Shinjuku station" - }, - "party": 3, - "rideStops": 4, - "seatSelection": true, - "signs": [ - { - "text": { - "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", - "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", - "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." - }, - "x": 5, - "y": 1 - }, - { - "text": { - "en": "\u003c- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) -\u003e", - "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", - "sv": "\u003c- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) -\u003e" - }, - "x": 11, - "y": 15 - }, - { - "text": { - "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", - "ja": "14番線 山手線 渋谷方面 → 南東口 改札", - "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." - }, - "x": 11, - "y": 5 - } - ], - "ticketRequired": true, - "timeLimitSec": 420, "trains": [ { "correct": true, @@ -113,5 +93,34 @@ }, "track": "8" } + ], + "signs": [ + { + "text": { + "en": "Shinjuku Station. Track 14 Yamanote Line for Shibuya: far bottom right. Track 8 Chuo Line for Tokyo: far bottom left.", + "ja": "新宿駅 14番線 山手線 渋谷方面 → 南東 8番線 中央線 東京方面 → 南西", + "sv": "Shinjuku station. Spår 14 Yamanote-linjen mot Shibuya: längst ner till höger. Spår 8 Chuo-linjen mot Tokyo: längst ner till vänster." + }, + "x": 5, + "y": 1 + }, + { + "text": { + "en": "<- Track 8 Chuo Line (Tokyo) Track 14 Yamanote Line (Shibuya) ->", + "ja": "← 8番線 中央線 東京方面 14番線 山手線 渋谷方面 →", + "sv": "<- Spår 8 Chuo-linjen (Tokyo) Spår 14 Yamanote-linjen (Shibuya) ->" + }, + "x": 11, + "y": 15 + }, + { + "text": { + "en": "Track 14 Yamanote Line for Shibuya: the gate in the south-east corner.", + "ja": "14番線 山手線 渋谷方面 → 南東口 改札", + "sv": "Spår 14 Yamanote-linjen mot Shibuya: spärren i sydöstra hörnet." + }, + "x": 11, + "y": 5 + } ] -} \ No newline at end of file +} diff --git a/resources/data/levels/002b-kabukicho.json b/resources/data/levels/002b-kabukicho.json new file mode 100644 index 0000000..ac19d38 --- /dev/null +++ b/resources/data/levels/002b-kabukicho.json @@ -0,0 +1,122 @@ +{ + "destination": { + "en": "Shinjuku", + "ja": "新宿", + "romaji": "Shinjuku", + "sv": "Shinjuku" + }, + "difficulty": 2, + "fareYen": 200, + "grid": [ + "#################################", + "#S....#....K#.....#.............#", + "#.###.#.#####.###.#.#.#########.#", + "#...#.#.........#.#.#.....#.....#", + "#.###.###########.#.#####.#######", + "#...#.#.....#...#.#.....#.#.....#", + "#.#.#.#.###.#.#.#.#.#####.#.###.#", + "#.#...#.#T....#.#.#.#...#.....#.#", + "#.#.###.#######.#.#.#.#.#####.#.#", + "#.#....i......#.#...#.#.....#.#.#", + "#.#####.#####.#.#####.#####.#.#.#", + "#.....#....W#.#.....#.#...#.#.#.#", + "#####.###.###.#####.#.#.#.#.###.#", + "#...#.#...#...#...#.#.#.#...#...#", + "#.#.#.###.#.###.#.#.#.#.#####.###", + "#.#.#...#.#.#.....#.#.#...#.....#", + "#.#.###.#.#.#.#####.#.###.#.#.#.#", + "#.#i..#.#.#.#.#...#...#...#...#.#", + "#G#.#.#.###.#.#.#.#####.#######G#", + "#pG.#.......#...#......i......GP#", + "#################################" + ], + "id": "002b-kabukicho", + "kind": "side", + "name": { + "en": "Kabukicho exit", + "ja": "歌舞伎町", + "sv": "Kabukicho-utgången" + }, + "order": 2, + "parent": "002-shinjuku", + "party": 2, + "rewards": { + "items": [ + "skin_kimono" + ], + "skillPoints": 1 + }, + "rideStops": 3, + "signs": [ + { + "text": { + "en": "Side trip: Kabukicho. Back to Shinjuku on the Yamanote Line, track 1 south-east. The Chuo Line goes the wrong way.", + "ja": "歌舞伎町 1番線 山手線 新宿方面 → 南東 3番線 中央線 東京方面", + "sv": "Sidospår: Kabukicho. Tillbaka till Shinjuku med Yamanote-linjen, spår 1 i sydost. Chuo-linjen går åt fel håll." + }, + "x": 7, + "y": 9 + }, + { + "text": { + "en": "\u003c- Track 3 Chuo Line (Tokyo) Track 1 Yamanote Line (Shinjuku) -\u003e", + "ja": "← 3番線 中央線 東京方面 1番線 山手線 新宿方面 →", + "sv": "\u003c- Spår 3 Chuo-linjen (Tokyo) Spår 1 Yamanote-linjen (Shinjuku) -\u003e" + }, + "x": 3, + "y": 17 + }, + { + "text": { + "en": "Track 1 Yamanote Line: the gate in the south-east corner.", + "ja": "1番線 山手線 新宿方面 → 南東口 改札", + "sv": "Spår 1 Yamanote-linjen: spärren i sydöstra hörnet." + }, + "x": 23, + "y": 19 + } + ], + "timeLimitSec": 240, + "trains": [ + { + "correct": true, + "destination": { + "en": "Shinjuku", + "ja": "新宿", + "romaji": "Shinjuku" + }, + "line": { + "en": "Yamanote Line", + "ja": "山手線", + "romaji": "Yamanote-sen" + }, + "platform": "P", + "schedule": { + "dwellSec": 7, + "intervalSec": 22, + "offsetSec": 4 + }, + "track": "1" + }, + { + "correct": false, + "destination": { + "en": "Tokyo", + "ja": "東京", + "romaji": "Tokyo" + }, + "line": { + "en": "Chuo Line", + "ja": "中央線", + "romaji": "Chuo-sen" + }, + "platform": "p", + "schedule": { + "dwellSec": 7, + "intervalSec": 26, + "offsetSec": 9 + }, + "track": "3" + } + ] +} \ No newline at end of file diff --git a/resources/data/levels/003-shibuya.json b/resources/data/levels/003-shibuya.json index dd7815c..fb96d03 100644 --- a/resources/data/levels/003-shibuya.json +++ b/resources/data/levels/003-shibuya.json @@ -1,12 +1,35 @@ { + "id": "003-shibuya", + "kind": "main", + "order": 3, + "name": { + "en": "Shibuya Station", + "ja": "渋谷駅", + "sv": "Shibuya station" + }, + "difficulty": 3, + "party": 4, + "ticketRequired": true, + "seatSelection": true, + "individualCards": true, + "rideStops": 5, "destination": { "en": "Ikebukuro", "ja": "池袋", "romaji": "Ikebukuro", "sv": "Ikebukuro" }, - "difficulty": 3, + "timeLimitSec": 360, + "maxMissedTrains": 1, "fareYen": 200, + "shop": [ + "sun_hat", + "happi", + "geta", + "map_shibuya", + "powerbank", + "rail_pass" + ], "grid": [ "#################################", "#S.........W#....i........#.....#", @@ -30,48 +53,6 @@ "#pG...............#.......#...GP#", "#################################" ], - "id": "003-shibuya", - "individualCards": true, - "maxMissedTrains": 1, - "name": { - "en": "Shibuya Station", - "ja": "渋谷駅", - "sv": "Shibuya station" - }, - "party": 4, - "rideStops": 5, - "seatSelection": true, - "signs": [ - { - "text": { - "en": "Shibuya Station. Track 2 Yamanote Line for Ikebukuro: south-east. Track 4 Tokyu Toyoko Line for Yokohama: south-west. Tickets at the machine.", - "ja": "渋谷駅 2番線 山手線 池袋方面 → 南東 4番線 東急東横線 横浜方面 → 南西 切符 自動券売機", - "sv": "Shibuya station. Spår 2 Yamanote-linjen mot Ikebukuro: sydöst. Spår 4 Tokyu Toyoko-linjen mot Yokohama: sydväst. Biljetter i automaten." - }, - "x": 17, - "y": 1 - }, - { - "text": { - "en": "\u003c- Track 4 Tokyu Toyoko Line (Yokohama) Track 2 Yamanote Line (Ikebukuro) -\u003e", - "ja": "← 4番線 東急東横線 横浜方面 2番線 山手線 池袋方面 →", - "sv": "\u003c- Spår 4 Tokyu Toyoko-linjen (Yokohama) Spår 2 Yamanote-linjen (Ikebukuro) -\u003e" - }, - "x": 23, - "y": 5 - }, - { - "text": { - "en": "Track 2 Yamanote Line for Ikebukuro: the gate in the south-east corner. Everyone needs a ticket and card balance.", - "ja": "2番線 山手線 池袋方面 → 南東口 改札", - "sv": "Spår 2 Yamanote-linjen mot Ikebukuro: spärren i sydöstra hörnet. Alla behöver biljett och saldo på kortet." - }, - "x": 29, - "y": 13 - } - ], - "ticketRequired": true, - "timeLimitSec": 360, "trains": [ { "correct": true, @@ -114,5 +95,34 @@ }, "track": "4" } + ], + "signs": [ + { + "text": { + "en": "Shibuya Station. Track 2 Yamanote Line for Ikebukuro: south-east. Track 4 Tokyu Toyoko Line for Yokohama: south-west. Tickets at the machine.", + "ja": "渋谷駅 2番線 山手線 池袋方面 → 南東 4番線 東急東横線 横浜方面 → 南西 切符 自動券売機", + "sv": "Shibuya station. Spår 2 Yamanote-linjen mot Ikebukuro: sydöst. Spår 4 Tokyu Toyoko-linjen mot Yokohama: sydväst. Biljetter i automaten." + }, + "x": 17, + "y": 1 + }, + { + "text": { + "en": "<- Track 4 Tokyu Toyoko Line (Yokohama) Track 2 Yamanote Line (Ikebukuro) ->", + "ja": "← 4番線 東急東横線 横浜方面 2番線 山手線 池袋方面 →", + "sv": "<- Spår 4 Tokyu Toyoko-linjen (Yokohama) Spår 2 Yamanote-linjen (Ikebukuro) ->" + }, + "x": 23, + "y": 5 + }, + { + "text": { + "en": "Track 2 Yamanote Line for Ikebukuro: the gate in the south-east corner. Everyone needs a ticket and card balance.", + "ja": "2番線 山手線 池袋方面 → 南東口 改札", + "sv": "Spår 2 Yamanote-linjen mot Ikebukuro: spärren i sydöstra hörnet. Alla behöver biljett och saldo på kortet." + }, + "x": 29, + "y": 13 + } ] -} \ No newline at end of file +} diff --git a/resources/data/skills.json b/resources/data/skills.json new file mode 100644 index 0000000..1954d5b --- /dev/null +++ b/resources/data/skills.json @@ -0,0 +1,239 @@ +{ + "_comment": "Språkträdet/skill tree. cost = skillpoäng (1 per level-up). requires = id:n som måste vara upplåsta. effect: stats (multiplikatorer: speed/thirst/focus/toilet/luck), kana, fogRadius (+rutor), landmarks (toaletter/kiosker/automater syns alltid på minikartan).", + "skills": [ + { + "id": "kana", + "branch": "språk", + "name": { + "sv": "Kana", + "en": "Kana" + }, + "desc": { + "sv": "Du kan läsa hiragana och katakana – alla kana-ord visas som romaji.", + "en": "You can read hiragana and katakana – kana words show as romaji." + }, + "cost": 2, + "requires": [], + "effect": { + "kana": true + } + }, + { + "id": "focus1", + "branch": "språk", + "name": { + "sv": "Skarp blick", + "en": "Sharp eye" + }, + "desc": { + "sv": "+25 % chans att lära ord av skyltar, snabbare hjälp.", + "en": "+25% chance to learn words from signs, faster help." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "focus": 1.25 + } + } + }, + { + "id": "focus2", + "branch": "språk", + "name": { + "sv": "Fotografiskt minne", + "en": "Photographic memory" + }, + "desc": { + "sv": "Ytterligare +25 % fokus.", + "en": "Another +25% focus." + }, + "cost": 2, + "requires": [ + "focus1" + ], + "effect": { + "stats": { + "focus": 1.25 + } + } + }, + { + "id": "polite", + "branch": "språk", + "name": { + "sv": "Sumimasen!", + "en": "Sumimasen!" + }, + "desc": { + "sv": "Folk hjälper dig gärna: hjälpen går dubbelt så fort.", + "en": "People like helping you: help takes half the time." + }, + "cost": 1, + "requires": [ + "focus1" + ], + "effect": { + "stats": { + "focus": 1.5 + } + } + }, + { + "id": "legs1", + "branch": "kropp", + "name": { + "sv": "Snabba fötter", + "en": "Quick feet" + }, + "desc": { + "sv": "+10 % gångfart.", + "en": "+10% walking speed." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "speed": 1.1 + } + } + }, + { + "id": "legs2", + "branch": "kropp", + "name": { + "sv": "Pendlarsteg", + "en": "Commuter stride" + }, + "desc": { + "sv": "Ytterligare +10 % gångfart.", + "en": "Another +10% speed." + }, + "cost": 2, + "requires": [ + "legs1" + ], + "effect": { + "stats": { + "speed": 1.1 + } + } + }, + { + "id": "camel", + "branch": "kropp", + "name": { + "sv": "Kamel", + "en": "Camel" + }, + "desc": { + "sv": "Törsten kommer 25 % långsammare.", + "en": "Thirst builds 25% slower." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "thirst": 0.75 + } + } + }, + { + "id": "bladder", + "branch": "kropp", + "name": { + "sv": "Stålblåsa", + "en": "Steel bladder" + }, + "desc": { + "sv": "Toamätaren sjunker 25 % långsammare.", + "en": "Toilet meter drops 25% slower." + }, + "cost": 1, + "requires": [ + "camel" + ], + "effect": { + "stats": { + "toilet": 0.75 + } + } + }, + { + "id": "lucky", + "branch": "resenär", + "name": { + "sv": "Omikuji", + "en": "Omikuji" + }, + "desc": { + "sv": "+30 % tur: färre omskanningar, lättare att få plats, färre matförgiftningar.", + "en": "+30% luck: fewer rescans, easier to squeeze in, fewer food poisonings." + }, + "cost": 1, + "requires": [], + "effect": { + "stats": { + "luck": 1.3 + } + } + }, + { + "id": "commuter", + "branch": "resenär", + "name": { + "sv": "Rusningsvan", + "en": "Rush-hour pro" + }, + "desc": { + "sv": "Ytterligare +30 % tur.", + "en": "Another +30% luck." + }, + "cost": 2, + "requires": [ + "lucky" + ], + "effect": { + "stats": { + "luck": 1.3 + } + } + }, + { + "id": "scout", + "branch": "utforskare", + "name": { + "sv": "Spanare", + "en": "Scout" + }, + "desc": { + "sv": "Du ser ett steg längre i gångarna.", + "en": "You see one step further down the corridors." + }, + "cost": 1, + "requires": [], + "effect": { + "fogRadius": 1 + } + }, + { + "id": "local", + "branch": "utforskare", + "name": { + "sv": "Lokalkännedom", + "en": "Local knowledge" + }, + "desc": { + "sv": "Toaletter, kiosker och automater syns alltid på minikartan.", + "en": "Toilets, kiosks and machines always show on the minimap." + }, + "cost": 2, + "requires": [ + "scout" + ], + "effect": { + "landmarks": true + } + } + ] +} diff --git a/resources/text/en.json b/resources/text/en.json index 1518813..1b9bd4a 100644 --- a/resources/text/en.json +++ b/resources/text/en.json @@ -20,7 +20,7 @@ "hud.steps": "Steps", "hud.controls": "Esc: menu M: map", "level.wrong_train": "Wrong train!", - "result.hint": "R: replay L: word list Enter/Esc: menu", + "result.hint": "R: replay L: word list Enter/Esc: map", "hud.card": "Card", "hud.track": "Track", "hud.next_train_in": "Next train in", @@ -132,5 +132,54 @@ "hud.arriving": "The train is pulling in…", "hud.closing": "Doors closing - board now!", "hud.leaving": "The train is leaving…", - "hud.train_near": "Train approaching!" + "hud.train_near": "Train approaching!", + "map.level": "Level", + "map.xp": "XP", + "map.skill_points": "Skill points", + "map.side": "Side trip", + "map.party": "Party", + "map.resume": "⟳ resume (saved seed)", + "map.locked": "Locked – clear the previous level first.", + "map.hint": "Arrows: level Enter: start L: words T: skills I: wardrobe P: phone Esc", + "skills.title": "Skills 技", + "skills.to_next": "XP to next", + "skills.already": "Already unlocked.", + "skills.unlocked": "Unlocked: %s", + "skills.need_points": "Costs %d skill points – level up by clearing levels.", + "skills.need_req": "Requires an earlier skill in the branch.", + "skills.hint": "Arrows: select Enter: unlock Esc/T: back", + "items.title": "Wardrobe 持ち物", + "items.owned": "items", + "items.owned_short": "owned", + "items.wearing": "worn", + "items.none": "Nothing yet – buy at kiosks (B) or clear side trips.", + "items.worn": "You wear %s.", + "items.removed": "You took off %s.", + "items.total": "Total", + "items.hint": "Arrows: select Enter: wear/remove Esc/I: back", + "shop.title": "Shop", + "shop.owned": "You already have that.", + "shop.bought": "Bought: %s", + "shop.too_expensive": "You cannot afford it.", + "shop.hint": "Arrows: select Enter: buy Esc/B: close", + "hud.shop_prompt": "B: shop", + "result.level_up": "Level up!", + "result.new_items": "New", + "result.unlocked": "Unlocked", + "stat.speed": "speed", + "stat.thirst": "thirst", + "stat.focus": "focus", + "stat.toilet": "toilet", + "stat.luck": "luck", + "slot.head": "head", + "slot.face": "face", + "slot.body": "body", + "slot.feet": "feet", + "slot.bag": "bag", + "effect.kana": "reads kana", + "effect.fog": "+%d sight", + "effect.landmarks": "landmarks on map", + "effect.free_gates": "free gates", + "effect.reveal": "map: %s", + "effect.battery": "infinite battery" } diff --git a/resources/text/jp-dictionary.json b/resources/text/jp-dictionary.json index bdc4872..f37c172 100644 --- a/resources/text/jp-dictionary.json +++ b/resources/text/jp-dictionary.json @@ -531,5 +531,13 @@ "en": "west", "sv": "väster", "tier": 2 + }, + "歌舞伎町": { + "kana": "かぶきちょう", + "romaji": "Kabukichō", + "en": "Kabukicho", + "sv": "Kabukicho", + "tier": 3, + "kind": "station" } } diff --git a/resources/text/sv.json b/resources/text/sv.json index e3d406a..f4c7500 100644 --- a/resources/text/sv.json +++ b/resources/text/sv.json @@ -20,7 +20,7 @@ "hud.steps": "Steg", "hud.controls": "Esc: meny M: karta", "level.wrong_train": "Fel tåg!", - "result.hint": "R: spela om L: ordlista Enter/Esc: meny", + "result.hint": "R: spela om L: ordlista Enter/Esc: karta", "hud.card": "Kort", "hud.track": "Spår", "hud.next_train_in": "Nästa tåg om", @@ -132,5 +132,54 @@ "hud.arriving": "Tåget rullar in…", "hud.closing": "Dörrarna stängs - kliv på nu!", "hud.leaving": "Tåget går…", - "hud.train_near": "Tåg på väg in!" + "hud.train_near": "Tåg på väg in!", + "map.level": "Nivå", + "map.xp": "XP", + "map.skill_points": "Skillpoäng", + "map.side": "Sidospår", + "map.party": "Sällskap", + "map.resume": "⟳ fortsätt (sparad seed)", + "map.locked": "Låst – klara föregående bana först.", + "map.hint": "Pilar: bana Enter: start L: ord T: skills I: garderob P: tel Esc", + "skills.title": "Skills 技", + "skills.to_next": "XP till nästa", + "skills.already": "Redan upplåst.", + "skills.unlocked": "Upplåst: %s", + "skills.need_points": "Kostar %d skillpoäng – gå upp i nivå genom att klara banor.", + "skills.need_req": "Kräver en tidigare skill i grenen.", + "skills.hint": "Pilar: välj Enter: lås upp Esc/T: tillbaka", + "items.title": "Garderob 持ち物", + "items.owned": "items", + "items.owned_short": "ägs", + "items.wearing": "bärs", + "items.none": "Inget än – köp i kioskerna (B) eller klara sidospår.", + "items.worn": "Du bär %s.", + "items.removed": "Du tog av %s.", + "items.total": "Totalt", + "items.hint": "Pilar: välj Enter: bär/ta av Esc/I: tillbaka", + "shop.title": "Butik", + "shop.owned": "Det har du redan.", + "shop.bought": "Köpt: %s", + "shop.too_expensive": "Du har inte råd.", + "shop.hint": "Pilar: välj Enter: köp Esc/B: stäng", + "hud.shop_prompt": "B: butik", + "result.level_up": "Nivå upp!", + "result.new_items": "Nytt", + "result.unlocked": "Upplåst", + "stat.speed": "fart", + "stat.thirst": "törst", + "stat.focus": "fokus", + "stat.toilet": "toa", + "stat.luck": "tur", + "slot.head": "huvud", + "slot.face": "ansikte", + "slot.body": "kropp", + "slot.feet": "fötter", + "slot.bag": "väska", + "effect.kana": "kan läsa kana", + "effect.fog": "+%d sikt", + "effect.landmarks": "landmärken på kartan", + "effect.free_gates": "gratis spärrar", + "effect.reveal": "karta: %s", + "effect.battery": "oändligt batteri" } diff --git a/tools/shots/main.go b/tools/shots/main.go index 70effdb..d3d6d35 100644 --- a/tools/shots/main.go +++ b/tools/shots/main.go @@ -17,8 +17,28 @@ import ( "strings" "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/level" + "gitea.brasse-pc.eu/brasse/metro-puzzle-jp/internal/save" ) +// demoSave skriver en sparfil med lite progression så att kartan, skills +// och garderoben har något att visa. Används bara av skärmdumparna. +func demoSave(path string) error { + st := save.New() + st.Know.Learn("改札") + st.Know.Learn("出口") + st.Know.Points = 2 + st.Progress.RecordLevel("001-flygplatsen", true, 3, 941) + st.Progress.RecordLevel("002-shinjuku", true, 2, 760) + st.Progress.AddXP(600) + st.Progress.Items["skin_salaryman"] = true + st.Progress.Items["geta"] = true + st.Progress.Items["water_bottle"] = true + st.Progress.Equipped["body"] = "skin_salaryman" + st.Progress.Equipped["feet"] = "geta" + st.Progress.Skills["focus1"] = true + return save.Store(path, st) +} + type shot struct { level string script string // $OUT ersätts med doc/img-mappen @@ -29,6 +49,8 @@ type shot struct { // bilderna överlever ändringar i banorna. var shots []shot +var demo string + func buildShots(levelsDir string) error { l, err := level.LoadFile(filepath.Join(levelsDir, "001-flygplatsen.json")) if err != nil { @@ -48,6 +70,10 @@ func buildShots(levelsDir string) error { if err != nil { return err } + toKiosk, err := moves(l, l.SpawnPoint(), l.Find(level.Kiosk)[0]) + if err != nil { + return err + } l2, err := level.LoadFile(filepath.Join(levelsDir, "002-shinjuku.json")) if err != nil { return err @@ -68,8 +94,9 @@ func buildShots(levelsDir string) error { // delvisa översättningen syns; hjälpen lär sedan in nästa ord. known := []string{"-know", "改札,番線,方面", "-seed", "7"} shots = []shot{ - {"", "wait 10; shot $OUT/menu.png; press l; wait 5; press down; press down; press down; wait 5; shot $OUT/words.png; quit", - []string{"-know", "改札,出口,トイレ", "-points", "3"}}, + {"", "wait 10; shot $OUT/map.png; press l; wait 5; press down; press down; press down; wait 5; shot $OUT/words.png; press l; wait 3; press t; wait 5; press down; wait 3; shot $OUT/skills.png; press t; wait 3; press i; wait 5; shot $OUT/items.png; quit", + []string{"-know", "改札,出口,トイレ", "-points", "3", "-save", "$DEMO"}}, + {"001-flygplatsen", toKiosk + "; press b; wait 5; press down; wait 3; shot $OUT/shop.png; quit", []string{"-seed", "7", "-save", "$DEMO"}}, {"001-flygplatsen", "move right 4; move down 2; wait 5; shot $OUT/level-001.png; quit", nil}, {"002-shinjuku", "move down 6; move right 4; wait 5; shot $OUT/level-002.png; quit", nil}, {"001-flygplatsen", toSign + "; wait 5; shot $OUT/sign.png; press h; wait 200; shot $OUT/sign-help.png; wait 200; shot $OUT/sign-learned.png; " + toGoal + @@ -124,6 +151,9 @@ func main() { root := repoRoot() out := filepath.Join(root, "doc", "img") must(os.MkdirAll(out, 0o755)) + demo = filepath.Join(root, "build", "demo-save.json") + must(os.MkdirAll(filepath.Dir(demo), 0o755)) + must(demoSave(demo)) must(buildShots(filepath.Join(root, "resources", "data", "levels"))) build := exec.Command("go", "run", "./tools/build", "-mode", "debug") @@ -134,12 +164,15 @@ func main() { if runtime.GOOS == "windows" { exe += ".exe" } + _ = demo for _, s := range shots { - args := []string{"-mute", "-script", strings.ReplaceAll(s.script, "$OUT", out)} + args := []string{"-mute", "-nosave", "-script", strings.ReplaceAll(s.script, "$OUT", out)} if s.level != "" { args = append(args, "-level", s.level) } - args = append(args, s.args...) + for _, a := range s.args { + args = append(args, strings.ReplaceAll(a, "$DEMO", demo)) + } run := exec.Command(exe, args...) run.Dir, run.Stdout, run.Stderr = filepath.Dir(exe), os.Stdout, os.Stderr if err := run.Run(); err != nil {