From a181f30b1edba2be4abb68e2c56fe2272565cb8e Mon Sep 17 00:00:00 2001 From: Halica Date: Tue, 6 Oct 2026 12:04:05 +0200 Subject: [PATCH] crude sprites bad sprites for enemies, some fairy changes --- light_and_dark/data/config/colors.json | 4 +- light_and_dark/data/config/fairy.json | 4 +- light_and_dark/data/config/ranged.json | 4 +- light_and_dark/data/config/ui_layout.json | 1 + light_and_dark/data/enemies/enemy_types.json | 20 ++ .../sprites/enemies/boss_flyer_minion.png | Bin 0 -> 421 bytes .../data/sprites/enemies/flying_boss_1.png | Bin 0 -> 1493 bytes .../data/sprites/enemies/flying_enemy_1.png | Bin 0 -> 376 bytes .../data/sprites/enemies/ground_boss_1.png | Bin 0 -> 1800 bytes .../data/sprites/enemies/ground_enemy_1.png | Bin 0 -> 505 bytes .../sprites/enemies/ground_miniboss_1.png | Bin 0 -> 883 bytes light_and_dark/data/world/rooms/room_09.json | 4 + light_and_dark/level_editor_v43.html | 7 + light_and_dark/main.py | 318 +++++++++++++++++- light_and_dark/saves/new sprites.json | 307 +++++++++++++++++ 15 files changed, 648 insertions(+), 21 deletions(-) create mode 100644 light_and_dark/data/sprites/enemies/boss_flyer_minion.png create mode 100644 light_and_dark/data/sprites/enemies/flying_boss_1.png create mode 100644 light_and_dark/data/sprites/enemies/flying_enemy_1.png create mode 100644 light_and_dark/data/sprites/enemies/ground_boss_1.png create mode 100644 light_and_dark/data/sprites/enemies/ground_enemy_1.png create mode 100644 light_and_dark/data/sprites/enemies/ground_miniboss_1.png create mode 100644 light_and_dark/saves/new sprites.json diff --git a/light_and_dark/data/config/colors.json b/light_and_dark/data/config/colors.json index 71ca63c..ed9804f 100644 --- a/light_and_dark/data/config/colors.json +++ b/light_and_dark/data/config/colors.json @@ -24,7 +24,9 @@ "boss": [240, 220, 80], "boss_hit": [255, 245, 160], "boss_flyer": [150, 90, 220], - "boss_flyer_hit": [210, 170, 255] + "boss_flyer_hit": [210, 170, 255], + "swooper": [90, 180, 220], + "swooper_hit": [170, 225, 255] }, "healing": { diff --git a/light_and_dark/data/config/fairy.json b/light_and_dark/data/config/fairy.json index 105c39e..e9cb741 100644 --- a/light_and_dark/data/config/fairy.json +++ b/light_and_dark/data/config/fairy.json @@ -34,7 +34,7 @@ "shade": "Ooh, shiny. Even the dark likes this." }, "dust_2": { - "light": "Another pinch of dust - keep going!", + "light": "A pinch of dust - keep going!", "shade": "The dark tastes sweeter with this." }, "dust_3": { @@ -50,7 +50,7 @@ "shade": "All 5! My sting is yours. Press the Fairy Mode key." }, "quest_return": { - "light": "That's all 5! Return to me at the fairy spot - I have something to show you!", + "light": "That's all 5! Return to the fairy spot - I have something to show you!", "shade": "All 5 gathered. Come back to my spot - I am ready to change." }, "quest_unlock": { diff --git a/light_and_dark/data/config/ranged.json b/light_and_dark/data/config/ranged.json index 0d2c4bc..1a77e36 100644 --- a/light_and_dark/data/config/ranged.json +++ b/light_and_dark/data/config/ranged.json @@ -8,8 +8,8 @@ "size": [14, 6] }, "fairy": { - "charge_cost": 5.0, - "cooldown": 0.7, + "charge_cost": 10.0, + "cooldown": 1.4, "range": 560.0, "voice_every": 3, "damage_mult": 0.5, diff --git a/light_and_dark/data/config/ui_layout.json b/light_and_dark/data/config/ui_layout.json index 73369d8..b9632c0 100644 --- a/light_and_dark/data/config/ui_layout.json +++ b/light_and_dark/data/config/ui_layout.json @@ -28,6 +28,7 @@ "pad_y": 6, "line_gap": 4, "x": 14, + "center_lift": 60, "y_offset_from_center": -10 } } diff --git a/light_and_dark/data/enemies/enemy_types.json b/light_and_dark/data/enemies/enemy_types.json index 31484bd..21b1291 100644 --- a/light_and_dark/data/enemies/enemy_types.json +++ b/light_and_dark/data/enemies/enemy_types.json @@ -88,6 +88,26 @@ "detection_range": 320, "contact_damage": 1, "drop_profile": "normal" + }, + + "swooper": { + "rect": [40, 40], + "health": 3, + "speed": 2.0, + "air_speed": 2.0, + "detection_range": 340, + "contact_damage": 1, + "drop_profile": "normal", + "behavior": "swooper", + "swooper": { + "bob_amplitude": 6, + "bob_speed": 2.4, + "charge_speed_mult": 3.4, + "charge_duration": 0.8, + "charge_cooldown": 1.8, + "return_speed": 2.2, + "windup": 0.0 + } } } diff --git a/light_and_dark/data/sprites/enemies/boss_flyer_minion.png b/light_and_dark/data/sprites/enemies/boss_flyer_minion.png new file mode 100644 index 0000000000000000000000000000000000000000..29fdb7c70e3a2edd063f8802379ec872ea748d57 GIT binary patch literal 421 zcmV;W0b2fvP)C3<0004ONkl^a5M89Oy zI4{#UFD1qrED+rN`8>g6syj-ffIz7_XywoXiJj5B&;W@XtqCdxT3IG3ZEBd~VfXPX zZIf6we7;2CB&W-bh2h#IQuQlS7>QE85KK6UFN^;l0U;t5NGQluheah9WVlYTK(DrFcCin;utI%BAZ@y8M_z)x=qp8Rwwd9^Cl`lD;T<-J{ z88j+6-&AzOk*K}rB6b4Cl|`f$=nWL71xG000G?Nkl zJ967F5I|R&oItl=;f&ifE?l^9p96S&2Q{kQ=nkG7z`Y9o)v`8 z0$30W&%P$HM1mw9b_swV0T_m17=~dOhG7_nVHk#C7)D!Gy>~#0?e+fHdmkwKm#vDr zRk0IXViGs=wB)WN`Iu%ElDqE!m+kfbs7+!D4U)h0+;|K(T1S(+H1qCooE~n@8dLWO z-d^vI`Y@jl z%MxN^+bG#SN~Vo_|FT`R7*eqWzdk-!r3qIq{!2R9(Lyi^9);{r&?0r^5q!P`&4ALr8b3QqVfQy1%U&y|}Yl*>ll8nW5$G z?r(u^Oajfrp%Ov?Qd-vZaI>PB;7qXOqz3M&Tc)J&k&&Kfz|2YOcaf_}?m>j+RVBH2 zdQ%rD(}PxbHw{n#Q!f^w_XB?x{*zICa?U9Bipq zhTM9@d!&&y!0F__q?k~r3O^s=suE{@I(H5m>zme%udXCqh(vJ7j*KK%>IMM3$qujC zmW(R6%j0szg5C3IY{OckwN_lsO?gYzM^yxeq*9?8#RB9DS^C#<%Rd)YaPeg-@=gnXX=ys6k#X(G>ZbkXNqc7fGVjr}k za+m~A7nQvuT2n(%E0|JRVHzsBcVkm2xOb&UprLUAC>2b}ttgF+qvt-U6D-xK(GWoz z_CcLsL__6B(kqJ93a+6=;~2H0r%G_`*j3~C7oe|XRe}Nfw|Ez+{n2(@!x8#%N2#(~ vl)D7#B3L69eF_*k$_&FW48t%C!_dTEDoo|-y0K4S00000NkvXXu0mjfq#oh{ literal 0 HcmV?d00001 diff --git a/light_and_dark/data/sprites/enemies/flying_enemy_1.png b/light_and_dark/data/sprites/enemies/flying_enemy_1.png new file mode 100644 index 0000000000000000000000000000000000000000..9310e3af5c27b68df366405e6c5e04106169196f GIT binary patch literal 376 zcmV-;0f+vHP)-bIl@z3$q?$?NF?I!xlH;Z!)^!USYacC{!>JFz zJmQ$ppdY<=-9m~3cnNpilH^GHC2A!FO2ROLn;c>n#SQ|z#kqL}H?MSD^u6>41vY^- z_S^ey6M~70V3Uj<~Qva&fzfMV{G5%kB|AZ%2H_Ickba zmw1U}lSqY4gE8^iDjj7q*-ZEHUe%L{9W_ObxWK8aqvmx>$|ZN3^o3PX6h%>#-{A=W W9Lj1~QEWH>0000 zJx?4-5Qb|{KuAJ%0k;twE|w6I(~~T4=)^z3fddj^>nsPBEypAmIM^R>2_bhniPRE8 z@)2?oA|Sg2GO#ok;KsvD_e_6ORaf=Q8^P?dHB(Plb#?X30>GfV>+Ooe&X6>rHc*GQ zym~@zZz%eS)7Th!cwz-N3QvOsS3=~ogOlPkDd!QZ!9`qdU|9CEfXnT?5;UlY%ROK) z?GFlEPGJw45u8#D23*cGNnv{s;7WrAkP%$&LV-h+*I0bSNgE(ojrQWfpqg?1` z2j|k=^>+2=U;lbJFjsY&Gs?w+%Ox{lq)RHov>TdKqX)RjRCC zqz%%1HfYb002j8Q`m)kz>JLj{nHBo_yLeQ!i~|GRiNjtLB)UHpg!CmmW{^Vt-R z&ijj(7}=(eN8oIEn0p`V?s~i0KfQ(h(_1yrDCP3Mb6wF_1*HJ(ny9q{J3GHXi@Gi! zJ74HqkJNK`C`1=HN@$D2^+&(IdT~i&2LK0NpHH20Ts`7!@ao4fz6F*On(G(VslcT| zGopqUGrdG_>nyLHOm=rRq86HU;2NsPlmRfGO|1=aqu81tX+Lgx^<-iI#z+G+47uQ? zEU8d7Qe)t0gNBuMjZzyMXf3a1*I&Zz0k2+MqB|Tz7~Lq;US)GX&HUVisHq2urTgo> zYTZ&F3z4M{S+78IK$B2i0^C=a^&JENzjd!oEV~0HjfSL8o3mpbCpBdPw6l@#Q$XyU ze*l2R&Ci|h>vM7pKn!pg?J!%^`KlhBtl;*JH!D|~40S9wJggpO2)M2^Dq~IJ@3TKbyS1Hw(bq7FxkM_(qxXJ!GQ3wOBvB(7T__;S?M*Q zm~)Y^ALk<$a9u@-6mbHZ&!(_<4At+y{*$*?If`Fu!D&fpR1&cq2$ghb^|_nsT-8OK zWN3@S^;W?}Re-4iCmmYIYxf1pX7qy~YQRZ`mP1*=`NEJ6EysG499-%et`un4aI>e% zE-CUciUsy*q; zjV=v3;-ubd=_68b+-tblyTxLZJ6em0xRmJ87!am`y4maMYCoVvpb+ksnG^7 zf>SHhTNlg@!5+jvg;KgOq7C3XxX)j{PGaY|61H==gar+t7ID7td_kH2`t zI*u)FJ6zSY6+#_gYD8fUh7?;=nW5>cu7?$|q78ufp^uRCt7(9^Y;sN;WL0I%9v;=X zP}s127Uy~x9bC#S3|bliUFXSl4XEI9IX=X0>N!feeFF`gmS`zODBwnDZBhF~eank; z&`27f>cNGolunN*aHTkvLt?jSy literal 0 HcmV?d00001 diff --git a/light_and_dark/data/sprites/enemies/ground_enemy_1.png b/light_and_dark/data/sprites/enemies/ground_enemy_1.png new file mode 100644 index 0000000000000000000000000000000000000000..f5a5467e9007d0fb004cbbaff14dbe7c3a18e0ec GIT binary patch literal 505 zcmV-v6(qYM1@~T-?RpGhyVgLm8r8xj zSwQyO1{`6~TvY>d#vP{5x)v**lBk{ZL29WbLHcJa{DOJZSD< zQa%SxF^b}tE_DF-a;I79*hTW1=PvD3C|SS(2&*_jmO#f4Al?6$?bcq+Zdh}TA#9-U zWig9u5@-*XWCf{SNiaA|iQkd~rg2l$P~w^tlAYpOxEx426j*@VPAnj+0rTLsDbqq$ z0ZQRY&vPIT0kv?YcD0ZsAO&Yp$u%*GfLiBDopT{kK&!OYih?{GgC204m_LBtYimiR zetYU6&(vbEG047jlY+ByxyFE>6nhuMQGfZ%Kd=YShNRTQ;Sps>6M&SO0ZcCDZo2QN zwd1)N2TQsp;F;6VH61)t=73h8n@RU_(4%v9x3*m7hN2*1iog9z0RXCxt$MM vNtI&Cy=D&4You{q40zU-%lls+>X-Nf$8Pgz=h=qt00000NkvXXu0mjf#W&=$ literal 0 HcmV?d00001 diff --git a/light_and_dark/data/sprites/enemies/ground_miniboss_1.png b/light_and_dark/data/sprites/enemies/ground_miniboss_1.png new file mode 100644 index 0000000000000000000000000000000000000000..93702f472dbe89f3e0d9648019093e6f26bc59ef GIT binary patch literal 883 zcmV-(1C0EMP)n*6Vn$_n z$aC*wKHfi@e;-Mf)Eou~Af`nr!!kK8Vp2+aX`zm*A(<8+_#;7{5KAe5!OZsvoJ46z z0gRfGR^^XSTO-N|)A?R*=YKz3W`*ZM(!Wbdhs8)t)kjC4>-}+(kka~HxA5LV(8$VJ ztpY6xwLa!(v+Ui-RDLrtJ-ef6DK9EX`FQ`F9>PcIiA~o;iW$A=ndQrpQcjsBsZN|z zPE`!&BBj<^wG%?o>!+%JINC&daLT{j&flbI4*9kE{FfMQDKp1t&x2~^e-mRNg^O1c zOH|+E^pJc(W^pbqd7XwQc$OoBCvQN^yXSGf9M@;1$fu1GX zOw=aoE{4MLl`QL#VDlF>F&1}R^Bkv<6L9+j?l!U7|L2@fawipa?xDoYtkd+4ky;R& zNl)t-(TB)Z8QeW|)G?Z=u2Egso~eNK#-1KCH!nYS_%6NzRWZP8%SlONZ!CTV^5(~S z$MOo4eNuC{j`|#9fymIdI<)|ZzYpS8B#LaRRSY+knG2ek+*vUECXxepqIBeVEjWFr z=$49+&e}^1w~lV3&a4>jRR8FR8hUf$2+~uFy@E%u#Sm;U1TJDC%wc-$#1L#T1X~Qj z7DHezW`&Ly*oh%A6|+iO07Ee=Ndb0KBH3aHwip5vF{|}2z=Da{-yh?Qx|kK45wK`t zEMzY!#sXC_Qg1D#${t}Sh9IPv2-{CWiP88p?ku6iq` z;*}Z|F;PFmJwk}Y^zcZHxESlSXt6|M0R9)^NFgfbS<(VHC1zHNWopF4thyjYArj|k z!?VOWOq){lekI533j$Y?0toL6r_hk1-JA|5rdI*2q-Z~1{Q-|2-@eD_x}^XB002ov JPDHLkV1kYKoI(Hq literal 0 HcmV?d00001 diff --git a/light_and_dark/data/world/rooms/room_09.json b/light_and_dark/data/world/rooms/room_09.json index 276c650..4dd94d1 100644 --- a/light_and_dark/data/world/rooms/room_09.json +++ b/light_and_dark/data/world/rooms/room_09.json @@ -22,6 +22,10 @@ "room9_bottom_right": [ 2660, 600 + ], + "entry_diary": [ + 1774, + 540 ] }, "platforms": [ diff --git a/light_and_dark/level_editor_v43.html b/light_and_dark/level_editor_v43.html index b3997e8..529eb35 100644 --- a/light_and_dark/level_editor_v43.html +++ b/light_and_dark/level_editor_v43.html @@ -303,6 +303,13 @@ } }, boss_minion: { rect: [34, 50], health: 4, speed: 2.4, air_speed: 2.2, detection_range: 320, contact_damage: 1, drop_profile: "normal" }, + swooper: { + rect: [40, 40], health: 3, speed: 2.0, air_speed: 2.0, detection_range: 340, contact_damage: 1, drop_profile: "normal", behavior: "swooper", + swooper: { + bob_amplitude: 6, bob_speed: 2.4, charge_speed_mult: 3.4, + charge_duration: 0.8, charge_cooldown: 1.8, return_speed: 2.2, windup: 0.0 + } + }, }; function seedDefaultObjectCatalog(){ diff --git a/light_and_dark/main.py b/light_and_dark/main.py index d68b528..f82f685 100644 --- a/light_and_dark/main.py +++ b/light_and_dark/main.py @@ -450,6 +450,9 @@ BOSS_HIT_COLOR = _color(["enemy", "boss_hit"]) FLYER_BOSS_COLOR = _color(["enemy", "boss_flyer"], (150, 90, 220)) FLYER_BOSS_HIT_COLOR = _color(["enemy", "boss_flyer_hit"], (210, 170, 255)) +SWOOPER_COLOR = _color(["enemy", "swooper"], (90, 180, 220)) +SWOOPER_HIT_COLOR = _color(["enemy", "swooper_hit"], (170, 225, 255)) + PLAYER_HIT_COLOR = (255, 255, 0) HEAL_DARK = _color(["healing", "dark"]) @@ -521,8 +524,8 @@ PROJECTILE_LIFETIME = float(_ranged(["projectile", "lifetime"], PROJECTILE_LIFET PROJECTILE_SIZE = tuple(_ranged(["projectile", "size"], list(PROJECTILE_SIZE))) # --- Fairy combat (granted by the Fairy Dust quest) --- -FAIRY_SHOT_CHARGE_COST = float(_ranged(["fairy", "charge_cost"], RANGED_CHARGE_COST)) -FAIRY_SHOT_COOLDOWN = float(_ranged(["fairy", "cooldown"], 0.7)) +FAIRY_SHOT_CHARGE_COST = float(_ranged(["fairy", "charge_cost"], RANGED_CHARGE_COST * 2.0)) +FAIRY_SHOT_COOLDOWN = float(_ranged(["fairy", "cooldown"], 1.4)) FAIRY_SHOT_RANGE = float(_ranged(["fairy", "range"], 560.0)) FAIRY_VOICE_EVERY = max(1, int(_ranged(["fairy", "voice_every"], 3))) FAIRY_DAMAGE_MULT = float(_ranged(["fairy", "damage_mult"], RANGED_DAMAGE_MULT)) @@ -983,6 +986,25 @@ ENEMY_TYPES_DEFAULT = { "contact_damage": 1, "drop_profile": "normal" }, + "swooper": { + "rect": [40, 40], + "health": 3, + "speed": 2.0, + "air_speed": 2.0, + "detection_range": 340, + "contact_damage": 1, + "drop_profile": "normal", + "behavior": "swooper", + "swooper": { + "bob_amplitude": 6, + "bob_speed": 2.4, + "charge_speed_mult": 3.4, + "charge_duration": 0.8, + "charge_cooldown": 1.8, + "return_speed": 2.2, + "windup": 0.0 + } + }, } ENEMY_DROPS_DEFAULT = { @@ -1564,6 +1586,42 @@ BOSS_PROJECTILE_RAW = _load_first_existing([ ]) BOSS_PROJECTILE_RAW_LEFT = pygame.transform.flip(BOSS_PROJECTILE_RAW, True, False) if BOSS_PROJECTILE_RAW is not None else None +# --- Enemy sprites (authored at each enemy's collision-rect size) --- +ENEMIES_SPRITES_DIR = DATA_ROOT / "sprites" / "enemies" + +def _load_enemy_sprite(name: str): + return _load_image_or_none(ENEMIES_SPRITES_DIR / name) + +ENEMY_SPRITES = { + "ground_enemy": _load_enemy_sprite("ground_enemy_1.png"), + "ground_miniboss": _load_enemy_sprite("ground_miniboss_1.png"), + "ground_boss": _load_enemy_sprite("ground_boss_1.png"), + "flying_enemy": _load_enemy_sprite("flying_enemy_1.png"), + "flying_boss": _load_enemy_sprite("flying_boss_1.png"), + "flying_boss_minion": _load_enemy_sprite("boss_flyer_minion.png"), +} + +def enemy_sprite_info(type_id: str, behavior: str): + """Return ``(raw_surface | None, source_facing)`` for an enemy. + + ``source_facing`` is ``"right"`` / ``"left"`` for art that must be mirrored + to match the enemy's facing, or ``None`` for art with no direction (drawn + as authored). A ``None`` surface means "no sprite yet" -> the entity keeps + its colored-rectangle placeholder.""" + if behavior == "flyer": + return ENEMY_SPRITES["flying_boss"], None + if behavior == "swooper": + return ENEMY_SPRITES["flying_enemy"], None + if behavior == "charger": + return ENEMY_SPRITES["ground_boss"], "left" + if behavior == "shockwave": + return None, None # shockwave boss art not made yet + if type_id == "boss_minion": + return ENEMY_SPRITES["flying_boss_minion"], None + if type_id == "miniboss": + return ENEMY_SPRITES["ground_miniboss"], "right" + return ENEMY_SPRITES["ground_enemy"], "right" + def _scale_icon(img: pygame.Surface | None, size: int) -> pygame.Surface | None: if img is None: return None @@ -1763,6 +1821,9 @@ class FairyProjectile(Projectile): w = max(1, int(img_raw.get_width() * scale)) h = max(1, int(img_raw.get_height() * scale)) img = pygame.transform.scale(img_raw, (w, h)) + # Raw fairy sprite points LEFT; flip it so it points right, then rotate + # to match the actual flight direction (same fix as the ranged bolt). + img = pygame.transform.flip(img, True, False) angle = -math.degrees(math.atan2(self.dir_y, self.dir_x)) img = pygame.transform.rotate(img, angle) surface.blit(img, (self.rect.centerx - img.get_width() // 2 - cx, @@ -3748,6 +3809,9 @@ class PickupFeed: self.pad_x = int(_ui(["pickup_feed", "pad_x"], 10)) self.pad_y = int(_ui(["pickup_feed", "pad_y"], 6)) self.line_gap = int(_ui(["pickup_feed", "line_gap"], 4)) + # Lift center "splash" messages above the normal feed line so they do + # not cover the fairy's speech bubble. + self.center_lift = int(_ui(["pickup_feed", "center_lift"], 60)) def push(self, name: str, rarity: str = ""): @@ -3830,7 +3894,7 @@ class PickupFeed: box_w = min(WIDTH - 80, w + pad_x * 2) box_h = h + pad_y * 2 box_x = (WIDTH - box_w) // 2 - box_y = y + box_y = y - self.center_lift box = pygame.Surface((box_w, box_h), pygame.SRCALPHA) box.fill((0, 0, 0, int(alpha * 0.8))) @@ -4995,6 +5059,29 @@ class Enemy: self.flyer_cycle_casts_left = 0 self.flyer_hard = False + # --- Hovering flyer ("swooper") state: the enemy idles in the air and, + # the moment it spots the player, dives straight at the point the player + # occupied when the charge began (that point stays frozen mid-dive). --- + swoop_cfg = (cfg.get("swooper", {}) if isinstance(cfg, dict) else {}) + if not isinstance(swoop_cfg, dict): + swoop_cfg = {} + self.swooper_bob_amplitude = float(swoop_cfg.get("bob_amplitude", 6)) + self.swooper_bob_speed = float(swoop_cfg.get("bob_speed", 2.4)) + self.swooper_charge_speed_mult = float(swoop_cfg.get("charge_speed_mult", 3.4)) + self.swooper_charge_duration = float(swoop_cfg.get("charge_duration", 0.8)) + self.swooper_charge_cooldown = float(swoop_cfg.get("charge_cooldown", 1.8)) + self.swooper_return_speed = float(swoop_cfg.get("return_speed", 2.2)) + self.swooper_windup = float(swoop_cfg.get("windup", 0.0)) + self.swooper_state = "idle" # idle | windup | charging | recover + self.swooper_timer = 0.0 + self.swooper_bob_t = random.random() * 10.0 + self.swooper_home_x = self.rect.centerx + self.swooper_home_y = self.rect.centery + self.swooper_dir_x = 0.0 + self.swooper_dir_y = 0.0 + self.swooper_target_x = float(self.rect.centerx) + self.swooper_target_y = float(self.rect.centery) + # --- summoned boss minion: expires after a short life, then heals the # boss; killing it early damages the boss. Color fades toward white. --- self.is_minion = (self.type_id == "boss_minion") @@ -5068,6 +5155,16 @@ class Enemy: self.flyer_startup_timer = self.flyer_startup_delay self.flyer_cycle_casts_left = 0 + self.swooper_state = "idle" + self.swooper_timer = 0.0 + self.swooper_bob_t = random.random() * 10.0 + self.swooper_home_x = self.rect.centerx + self.swooper_home_y = self.rect.centery + self.swooper_dir_x = 0.0 + self.swooper_dir_y = 0.0 + self.swooper_target_x = float(self.rect.centerx) + self.swooper_target_y = float(self.rect.centery) + self.turn_cooldown = 0.0 self.last_x = self.rect.x self.stuck_timer = 0.0 @@ -5559,6 +5656,157 @@ class Enemy: )) self.flyer_aoe_cd = self.flyer_aoe_cooldown + # --- Hovering flyer ("swooper") behavior --- + + def _swooper_update(self, dt, platforms, player): + """Idle in the air bobbing, then -- the instant the player is spotted -- + lock onto where the player stood at that moment and dive at that frozen + point. After the dive it recovers, drifts back to its perch and can + charge again once the cooldown elapses.""" + if self.hit_timer > 0.0: + self.hit_timer -= dt + if self.bind_timer > 0.0: + self.bind_timer = max(0.0, self.bind_timer - dt) + if self.fear_timer > 0.0: + self.fear_timer = max(0.0, self.fear_timer - dt) + if self.slow_timer > 0.0: + self.slow_timer = max(0.0, self.slow_timer - dt) + if self.slow_timer == 0.0: + self.slow_mult = 1.0 + self._behavior_tick(dt) + + stunned = self.stun_timer > 0.0 + if stunned: + self.stun_timer = max(0.0, self.stun_timer - dt) + + dx = player.rect.centerx - self.rect.centerx + dy = player.rect.centery - self.rect.centery + distance = math.hypot(dx, dy) + sees_player = (not player.shade) and (distance <= self.detection_range) + feared = self.fear_timer > 0.0 + speed = self.air_speed + if self.slow_timer > 0.0: + speed *= self.slow_mult + + if feared: + # Flee straight away from the player and abort any dive. + if distance > 0.001: + self._swooper_try_move(-dx / distance * TETHER_FEAR_SPEED_MULT * speed, + -dy / distance * TETHER_FEAR_SPEED_MULT * speed, + platforms) + self.swooper_state = "idle" + self.swooper_timer = 0.0 + self.was_chasing = False + return + + if stunned: + self.swooper_state = "idle" + self.swooper_timer = 0.0 + self.was_chasing = False + return + + if self.swooper_state == "idle": + self._swooper_hover(dt, platforms) + if sees_player: + self._swooper_start_charge(player) + elif self.swooper_state == "windup": + self._swooper_hover(dt, platforms) + self.swooper_timer -= dt + if self.swooper_timer <= 0.0: + self.swooper_state = "charging" + self.swooper_timer = self.swooper_charge_duration + elif self.swooper_state == "charging": + charge_speed = speed * self.swooper_charge_speed_mult + blocked = self._swooper_try_move( + self.swooper_dir_x * charge_speed, + self.swooper_dir_y * charge_speed, + platforms, + ) + self.swooper_timer -= dt + # Remaining vector to the frozen target; once it points behind us + # (or we hit something / ran out of time) the dive is over. + rem_x = self.swooper_target_x - self.rect.centerx + rem_y = self.swooper_target_y - self.rect.centery + passed_target = (rem_x * self.swooper_dir_x + rem_y * self.swooper_dir_y) <= 0.0 + if blocked or self.swooper_timer <= 0.0 or passed_target: + self.swooper_state = "recover" + self.swooper_timer = self.swooper_charge_cooldown + else: # recover + self._swooper_hover(dt, platforms) + self.swooper_timer -= dt + if self.swooper_timer <= 0.0: + self.swooper_state = "idle" + + self.was_chasing = sees_player + + def _swooper_hover(self, dt, platforms): + """Ease back toward the perch while bobbing gently on the y axis.""" + self.swooper_bob_t += dt * self.swooper_bob_speed + bob = math.sin(self.swooper_bob_t) * self.swooper_bob_amplitude + to_x = self.swooper_home_x - self.rect.centerx + to_y = (self.swooper_home_y + bob) - self.rect.centery + dist = math.hypot(to_x, to_y) + if dist < 0.001: + return + hover_speed = self.swooper_return_speed + if self.slow_timer > 0.0: + hover_speed *= self.slow_mult + step = min(hover_speed, dist) + self._swooper_try_move(to_x / dist * step, to_y / dist * step, platforms) + + def _swooper_start_charge(self, player): + """Freeze the player's position and begin the dive toward it.""" + self.swooper_target_x = float(player.rect.centerx) + self.swooper_target_y = float(player.rect.centery) + dx = self.swooper_target_x - self.rect.centerx + dy = self.swooper_target_y - self.rect.centery + length = math.hypot(dx, dy) + if length < 1.0: + dx, dy, length = 1.0, 0.0, 1.0 + self.swooper_dir_x = dx / length + self.swooper_dir_y = dy / length + self.direction = 1 if self.swooper_dir_x >= 0 else -1 + if self.swooper_windup > 0.0: + self.swooper_state = "windup" + self.swooper_timer = self.swooper_windup + else: + self.swooper_state = "charging" + self.swooper_timer = self.swooper_charge_duration + + def _swooper_try_move(self, dx, dy, platforms) -> bool: + """Move by a fractional per-frame delta, stopping on platforms and + clamping to the room. Returns True if a platform blocked the move.""" + blocked = False + if dx: + self.rect.x += int(dx) + for p in platforms: + if self.rect.colliderect(p): + if dx > 0: + self.rect.right = p.left + else: + self.rect.left = p.right + blocked = True + break + if dy: + self.rect.y += int(dy) + for p in platforms: + if self.rect.colliderect(p): + if dy > 0: + self.rect.bottom = p.top + else: + self.rect.top = p.bottom + blocked = True + break + lo = getattr(ROOM, "min_x", None) + hi = getattr(ROOM, "max_x", None) + if lo is not None: + self.rect.left = max(self.rect.left, lo) + if hi is not None: + self.rect.right = min(self.rect.right, hi) + room_h = getattr(ROOM, "room_h", 900) + self.rect.y = max(20, min(self.rect.y, room_h - self.rect.height)) + return blocked + def update(self, dt, platforms, ground_platforms, player, shockwaves_list: list[Shockwave], projectiles_list=None, enemies_list=None, aoes_list=None): # Summoned minions expire after their short life: no drops, heals the boss. @@ -5580,6 +5828,11 @@ class Enemy: ) return + # Hovering flyer ("swooper"): also fully aerial; replaces the ground body. + if self.behavior == "swooper": + self._swooper_update(dt, platforms, player) + return + if self.jump_cd_timer > 0: self.jump_cd_timer -= dt @@ -6012,6 +6265,8 @@ class Enemy: def draw(self, surface, cx, cy): if self.behavior == "flyer": color = FLYER_BOSS_HIT_COLOR if self.hit_timer > 0 else FLYER_BOSS_COLOR + elif self.behavior == "swooper": + color = SWOOPER_HIT_COLOR if self.hit_timer > 0 else SWOOPER_COLOR elif self.drop_profile == "boss": color = BOSS_HIT_COLOR if self.hit_timer > 0 else BOSS_COLOR elif self.drop_profile == "miniboss": @@ -6020,16 +6275,38 @@ class Enemy: color = ENEMY_HIT_COLOR if self.hit_timer > 0 else ENEMY_COLOR # Summoned minions fade toward white as their life runs out. + fade = 0.0 if getattr(self, "is_minion", False) and self.minion_duration > 0: frac = max(0.0, min(1.0, self.minion_timer / self.minion_duration)) - light = 1.0 - frac - color = tuple(int(c + (255 - c) * light * 0.9) for c in color) + fade = (1.0 - frac) * 0.9 - pygame.draw.rect( - surface, - color, - pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) - ) + raw, source_facing = enemy_sprite_info(self.type_id, self.behavior) + if raw is not None: + img = raw + if source_facing == "right" and self.direction < 0: + img = pygame.transform.flip(img, True, False) + elif source_facing == "left" and self.direction > 0: + img = pygame.transform.flip(img, True, False) + draw_x = self.rect.centerx - img.get_width() // 2 - cx + draw_y = self.rect.bottom - img.get_height() - cy + surface.blit(img, (draw_x, draw_y)) + if self.hit_timer > 0: + flash = img.copy() + flash.fill((140, 140, 140, 0), special_flags=pygame.BLEND_RGBA_ADD) + surface.blit(flash, (draw_x, draw_y)) + if fade > 0.0: + add = int(255 * fade) + glow = img.copy() + glow.fill((add, add, add, 0), special_flags=pygame.BLEND_RGBA_ADD) + surface.blit(glow, (draw_x, draw_y)) + else: + if fade > 0.0: + color = tuple(int(c + (255 - c) * fade) for c in color) + pygame.draw.rect( + surface, + color, + pygame.Rect(self.rect.x - cx, self.rect.y - cy, self.rect.width, self.rect.height) + ) if self.reality_break_glow: pygame.draw.rect( @@ -8466,8 +8743,12 @@ diary_ui = DiaryUI(inventory, player, font) # ---------------------------- TOWN_NPC_TYPES = ("vendor", "upgrader", "necklace_vendor", "combat_master") +# The current first town (where the Boss Diary lives). +TOWN_ROOM_ID = "room_09.json" + TOWN_DISPLAY_NAMES = { "room_town_gloomreach.json": "Gloomreach", + "room_09.json": "Gloomreach", } @@ -9749,11 +10030,12 @@ while running: # Respawn on death/void if player.rect.top > KILL_Y or player.health <= 0: - apply_death_penalty() if ACTIVE_REFIGHT is not None: - # Died in a diary refight -> return to the diary in town. + # Died in a diary refight. Refights are consequence-free practice: + # skip the death penalty entirely (no coin/jar loss, no corpse) and + # return the player to the diary in town. ACTIVE_REFIGHT = None - room = room_manager.load_room("room_town_gloomreach.json") + room = room_manager.load_room(TOWN_ROOM_ID) diary_spawn = room.entry_spawns.get("entry_diary") or room.player_spawn room.player_spawn = [int(diary_spawn[0]), int(diary_spawn[1])] player.respawn(int(diary_spawn[0]), int(diary_spawn[1])) @@ -9761,6 +10043,7 @@ while running: player.shatterstep_landing_pending = False player.shatterstep_landing_damage = 0.0 else: + apply_death_penalty() # Normal death respawn: latest lantern save point, else the room spawn. if LANTERN_RESPAWN_ROOM and LANTERN_RESPAWN_POS: lx, ly = LANTERN_RESPAWN_POS @@ -10155,12 +10438,15 @@ while running: # Fairy companion bubble: active line, or a talk prompt when at a fairy spot. fairy_rect = player.fairy_screen_rect if fairy_rect is not None: + # Anchor the bubble above the player rather than the fairy: the fairy + # swaps sides when the player flips, which made the text jump. + bubble_x = player.rect.centerx - camera_x if fairy_speech_text: - draw_speech_bubble(screen, fairy_speech_text, fairy_rect.centerx, fairy_rect.top) + draw_speech_bubble(screen, fairy_speech_text, bubble_x, fairy_rect.top) elif get_near_fairy_dust(player, fairy_dusts): - draw_speech_bubble(screen, "(Press F to collect)", fairy_rect.centerx, fairy_rect.top) + draw_speech_bubble(screen, "(Press F to collect)", bubble_x, fairy_rect.top) elif get_near_fairy_spot(player, fairy_spots): - draw_speech_bubble(screen, "(Press F to talk)", fairy_rect.centerx, fairy_rect.top) + draw_speech_bubble(screen, "(Press F to talk)", bubble_x, fairy_rect.top) # NPC greeting bubble (shown near the player) greet_npc = get_near_npc(player, npcs) diff --git a/light_and_dark/saves/new sprites.json b/light_and_dark/saves/new sprites.json new file mode 100644 index 0000000..7ef485f --- /dev/null +++ b/light_and_dark/saves/new sprites.json @@ -0,0 +1,307 @@ +{ + "version": 1, + "saved_at": "2026-10-06T09:06:07", + "player": { + "room_id": "room_09.json", + "x": 1460, + "y": 660, + "health": 20, + "charge": 115.0, + "shade": true, + "coins_light": 92, + "coins_dark": 22, + "butterfly_jars": 1, + "butterfly_jar_seen": true, + "has_ranged": true, + "has_fairy_combat": true, + "opened_chests": [ + "room_03.json@90,194", + "room_07.json@440,494" + ], + "equipment": { + "helmet": { + "id": "gravemark_helm", + "name": "Gravemark Helmet", + "set": "gravemark", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge": 10 + } + }, + "attuned": { + "extra_stats": { + "charge": 15, + "charge_regen_pct": 0.1 + } + }, + "awakened": { + "extra_stats": { + "charge": 15, + "charge_regen_pct": 0.15, + "health": 5 + } + } + }, + "rarity": "awakened", + "tier": 3, + "stats": { + "charge": 15.0, + "charge_regen_pct": 0.15, + "health": 5.0 + }, + "abilities": [] + }, + "chest": { + "id": "gravemark_chest", + "name": "Gravemark Chestplate", + "set": "gravemark", + "slot": "chest", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge_regen_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "charge_regen_pct": 0.1, + "health": 5 + } + }, + "awakened": { + "extra_stats": { + "charge_regen_pct": 0.2, + "health": 10 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "charge_regen_pct": 0.1 + }, + "abilities": [] + }, + "pants": { + "id": "vitalweave_greaves", + "name": "Vitalweave Greaves", + "set": "vitalweave", + "slot": "pants", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge_regen_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "charge_regen_pct": 0.15 + } + }, + "awakened": { + "extra_stats": { + "charge_regen_pct": 0.35 + } + } + }, + "rarity": "attuned", + "tier": 2, + "stats": { + "charge_regen_pct": 0.15 + }, + "abilities": [] + }, + "necklace": { + "id": "necklace_vigor", + "name": "Charm of Vigor", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "health": 5 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "health": 5 + } + } + } + }, + "inventory": [ + { + "id": "vitalweave_helm", + "name": "Vitalweave Helmet", + "set": "vitalweave", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "health": 5 + } + }, + "attuned": { + "extra_stats": { + "health": 10 + } + }, + "awakened": { + "extra_stats": { + "health": 15 + } + } + }, + "rarity": "attuned", + "tier": 2, + "stats": { + "health": 10.0 + }, + "abilities": [] + }, + { + "id": "necklace_haste", + "name": "Charm of Haste", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "move_speed_pct": 0.1 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "move_speed_pct": 0.1 + } + }, + { + "id": "necklace_haste", + "name": "Charm of Haste", + "slot": "necklace", + "item_type": "necklace", + "base_stats": { + "move_speed_pct": 0.1 + }, + "rarity": "", + "tier": 0, + "abilities": [], + "stats": { + "move_speed_pct": 0.1 + } + }, + { + "id": "adaptive_traveller_helm", + "name": "Adaptive Traveller's Helmet", + "set": "adaptive_traveller", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge": 10 + } + }, + "attuned": { + "extra_stats": { + "charge": 15 + } + }, + "awakened": { + "extra_stats": { + "charge": 15, + "charge_regen_pct": 0.15 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "charge": 10.0 + }, + "abilities": [] + }, + { + "id": "luminal_growth_helm", + "name": "Luminal Growth Helmet", + "set": "luminal_growth", + "slot": "helmet", + "base_stats": {}, + "rarity_overrides": { + "tempered": { + "extra_stats": { + "charge_regen_pct": 0.1 + } + }, + "attuned": { + "extra_stats": { + "charge_regen_pct": 0.1, + "health": 5 + } + }, + "awakened": { + "extra_stats": { + "charge_regen_pct": 0.15, + "health": 10, + "negate_damage_chance": 0.05 + } + } + }, + "rarity": "tempered", + "tier": 1, + "stats": { + "charge_regen_pct": 0.1 + }, + "abilities": [] + } + ], + "quests": { + "active": { + "first_kills_shade": { + "progress": 0 + }, + "quest_combat_master_tokens": { + "progress": 1 + } + }, + "completed_one_time": [ + "quest_fairy_dust" + ], + "ever_completed": [ + "quest_fairy_dust", + "quest_first_kills" + ], + "tokens": 0 + }, + "boss_diary": { + "slotted": { + "boss_02": 1 + } + }, + "defeated_bosses": [ + "room_05.json", + "room_08.json" + ], + "broken_doors": [], + "pending_token_drops": {}, + "opened_miniboss_walls": [ + "room_04.json:1340,540,20,160", + "room_04.json:460,540,20,160" + ], + "fairy_dust_collected": [ + "room_01.json:600,140", + "room_03.json:440,400", + "room_05.json:740,180", + "room_07.json:1100,520", + "room_09.json:840,560" + ], + "lantern_respawn": { + "room_id": "room_09.json", + "x": 1460, + "y": 660 + } +} \ No newline at end of file