ReC98/th01/main/boss/b15j.cpp

1171 lines
31 KiB
C++
Raw Normal View History

/// Jigoku Stage 15 Boss - Kikuri
/// -----------------------------
#include <stddef.h>
#include "th01/main/boss/palette.cpp"
#include "x86real.h"
#include "planar.h"
#include "master.hpp"
#include "th01/v_colors.hpp"
#include "th01/hardware/egc.h"
extern "C" {
#include "th01/hardware/frmdelay.h"
#include "th01/hardware/graph.h"
#include "th01/hardware/input.hpp"
#include "th01/snd/mdrv2.h"
}
#include "th01/formats/ptn.hpp"
#include "th01/hardware/grpinv32.hpp"
#include "th01/formats/pf.hpp"
#include "th01/math/area.hpp"
#include "th01/math/overlap.hpp"
#include "th01/math/polar.hpp"
#include "th01/math/subpixel.hpp"
#include "th01/sprites/pellet.h"
#include "th01/main/particle.hpp"
#include "th01/main/playfld.hpp"
#include "th01/formats/stagedat.hpp"
#include "th01/main/vars.hpp"
#include "th01/main/hud/hp.hpp"
#include "th01/main/bullet/laser_s.hpp"
#include "th01/main/bullet/pellet.hpp"
#include "th01/main/boss/boss.hpp"
#include "th01/main/boss/defeat.hpp"
#include "th01/main/boss/entity_a.hpp"
#include "th01/main/player/orb.hpp"
#include "th01/main/player/player.hpp"
#include "th01/main/stage/palette.hpp"
#include "th01/main/stage/stageobj.hpp"
// Coordinates
// -----------
static const screen_x_t DISC_CENTER_X = 320;
static const screen_y_t DISC_CENTER_Y = 180;
static const pixel_t DISC_RADIUS = 90;
static const screen_x_t EYE_LEFT_CENTER_X = 307;
static const screen_x_t EYE_RIGHT_CENTER_X = 336;
static const screen_y_t EYE_BOTTOM = 147;
// That's… not quite where the light ball is?
static const screen_x_t LIGHTBALL_CENTER_X = 320;
static const screen_y_t LIGHTBALL_CENTER_Y = 224;
static const pixel_t HITBOX_W = 96;
static const pixel_t HITBOX_H = 48;
static const screen_x_t HITBOX_LEFT = (
DISC_CENTER_X - (HITBOX_W / 2) - (ORB_W / 2)
);
static const screen_x_t HITBOX_RIGHT = (
DISC_CENTER_X + (HITBOX_W / 2) - (ORB_W / 2)
);
// Not the actual Y coordinates of the original hitbox, due to a sign confusion
// bug in kikuri_hittest_orb()!
static const screen_y_t HITBOX_TOP = (DISC_CENTER_Y - HITBOX_H);
static const screen_y_t HITBOX_BOTTOM = DISC_CENTER_Y;
static const pixel_t SOUL_W = 32;
static const pixel_t SOUL_H = 32;
static const pixel_t TEAR_W = 16;
static const pixel_t TEAR_H = 16;
static const pixel_t RIPPLE_W = 16;
static const pixel_t RIPPLE_H = 16;
static const screen_x_t SOUL_AREA_LEFT = (PLAYFIELD_LEFT + (PLAYFIELD_W / 20));
static const screen_y_t SOUL_AREA_TOP = PLAYFIELD_TOP;
static const screen_x_t SOUL_AREA_RIGHT = (
PLAYFIELD_RIGHT - (PLAYFIELD_W / 20) - SOUL_W
);
static const screen_y_t SOUL_AREA_BOTTOM = PLAYFIELD_BOTTOM;
static const screen_y_t TEAR_TOP_MAX = (PLAYFIELD_BOTTOM - TEAR_H);
// -----------
enum kikuri_colors_t {
COL_LIGHTBALL = 5,
COL_LASER = 10,
};
// Always denotes the last phase that ends with that amount of HP.
enum kikuri_hp_t {
HP_TOTAL = 14,
PHASE_2_END_HP = 10,
PHASE_5_END_HP = 6,
PHASE_6_END_HP = 0,
};
// Global boss state that is defined here for some reason
// ------------------------------------------------------
Palette4 boss_palette;
// ------------------------------------------------------
// State that's suddenly no longer shared with other bosses
// --------------------------------------------------------
#define boss_phase kikuri_phase
int8_t boss_phase;
// --------------------------------------------------------
// Entities
// --------
static const int SOUL_COUNT = 2;
static const int SOUL_CELS = 3;
static const int TEAR_COUNT = 10;
// Nonzero values are also used to indicate whether a given tear is alive.
int8_t tear_anim_frame[TEAR_COUNT];
CBossEntity souls_raw[SOUL_COUNT + 3];
CBossEntity tears[10];
#define souls reinterpret_cast<CBossEntitySized<SOUL_W, SOUL_H> *>(souls_raw)
#define kikuri_ent_load(tmp_i) { \
souls[0].load("tamasii.bos", 0); \
souls[1].metadata_assign(souls[0]); \
\
tears[0].load("tamasii2.bos", 1); \
kikuri_ptn_load(); /* MODDERS: Don't nest these load calls. */ \
\
for(i = 1; i < TEAR_COUNT; i++) { \
tears[i].metadata_assign(tears[0]); \
} \
}
inline void kikuri_ent_free() {
bos_entity_free(0);
bos_entity_free(1);
}
// --------
// .PTN
// ----
static const main_ptn_slot_t PTN_SLOT_RIPPLE = PTN_SLOT_BOSS_1;
static const int PTN_RIPPLE = PTN_ID(PTN_SLOT_RIPPLE, 0);
enum kikuri_ripple_cel_t {
C_RIPPLE_LOW = 0,
C_RIPPLE_HIGH = 1,
C_RIPPLE_FILLED = 2,
C_RIPPLE_EMPTY = 3,
};
inline void kikuri_ptn_load(void) {
ptn_load(PTN_SLOT_RIPPLE, "tamayen.ptn");
}
inline void kikuri_ptn_free(void) {
ptn_free(PTN_SLOT_RIPPLE);
}
// ----
// Patterns
// --------
enum kikuri_phase_4_subphase_t {
P4_SOUL_ACTIVATION = 0,
P4_PATTERN = 1,
P4_DONE = 2,
_kikuri_phase_4_subphase_t_FORCE_INT16 = 0x7FFF
};
union {
int interval;
int speed_multiplied_by_8;
} pattern_state;
// --------
#define select_for_rank kikuri_select_for_rank
#include "th01/main/select_r.cpp"
void kikuri_load(void)
{
int i;
int j;
pellet_interlace = true;
Pellets.unknown_seven = 7;
palette_copy(boss_palette, z_Palettes, i, j);
for(i = 0; i < TEAR_COUNT; i++) {
tear_anim_frame[i] = 0;
}
kikuri_ent_load(i);
void kikuri_setup(void);
kikuri_setup();
// (redundant, no particles are shown in this fight)
particles_unput_update_render(PO_INITIALIZE, V_WHITE);
}
void kikuri_setup(void)
{
int col;
int comp;
boss_phase = 0;
boss_phase_frame = 0;
// Same HP and phase settings as Elis.
boss_hp = HP_TOTAL;
hud_hp_first_white = PHASE_2_END_HP;
hud_hp_first_redwhite = PHASE_5_END_HP;
// Redundant already called before the sprites are first rendered, and
// (0, 0) isn't used to indicate "soul is not alive".
souls[0].pos_set(0, 0, 50,
SOUL_AREA_LEFT, SOUL_AREA_RIGHT, SOUL_AREA_TOP, SOUL_AREA_BOTTOM
);
souls[1].pos_set(0, 0, 50,
SOUL_AREA_LEFT, SOUL_AREA_RIGHT, SOUL_AREA_TOP, SOUL_AREA_BOTTOM
);
palette_set_grayscale(boss_post_defeat_palette, 0x0, col, comp);
}
void kikuri_free(void)
{
kikuri_ent_free();
kikuri_ptn_free();
}
bool16 near kikuri_hittest_orb(void)
{
// Did you mean: > HITBOX_TOP? Using < describes a hitbox from the top of
// the playfield *until* that point instead...
if(
(orb_cur_left > HITBOX_LEFT) && (orb_cur_left < HITBOX_RIGHT) &&
(orb_cur_top < HITBOX_TOP) && (orb_cur_top < HITBOX_BOTTOM)
) {
return true;
}
return false;
}
void pascal near soul_move_and_render(int i, pixel_t delta_x, pixel_t delta_y)
{
souls[i].move_lock_unput_and_put_8(0, delta_x, delta_y, 1);
if((boss_phase_frame % 12) == 0) {
if(souls[i].image() >= (SOUL_CELS - 1)) {
souls[i].set_image(0);
} else {
souls[i].set_image(souls[i].image() + 1);
}
}
}
void pascal near tears_add(screen_x_t left, screen_y_t top)
{
for(int i = 0; i < TEAR_COUNT; i++) {
if(tear_anim_frame[i] == 0) {
tears[i].pos_set(
left, top, 50,
SOUL_AREA_LEFT, SOUL_AREA_RIGHT, SOUL_AREA_TOP, SOUL_AREA_BOTTOM
);
tears[i].set_image(0);
tear_anim_frame[i] = 1;
return;
}
}
}
bool16 pascal near tear_ripple_hittest(screen_x_t left, pixel_t extra_w)
{
if(player_invincible != true) {
// Translation: 8 pixels in Reimu's center vs. 10 pixels in the ripple
// sprite's center.
//
// ZUN bug: … except that ripples are rendered at byte-aligned X
// positions. This collision detection uses the internal X coordinate
// though, which can be (and due to the deterministic nature of this
// part of the fight, regularly is) anywhere within that byte. This
// ends up shifting this otherwise logical hitbox up to 7 pixels to the
// right, compared to where you would expect it based on how the
// sprites appear on screen.
//
// (The ideal fix would be to introduce unaligned rendering for these
// sprites, rather than byte-aligning the coordinates here. The latter
// would fork the game, obviously.)
if(
(player_left >= (left - ((PLAYER_W / 4) + (RIPPLE_W / 2)))) &&
(player_left <= (left + extra_w))
) {
done = true;
return true;
}
}
return false;
}
void near tears_update_and_render(void)
{
for(int i = 0; i < TEAR_COUNT; i++) {
if(tear_anim_frame[i] != 0) {
if(tears[i].cur_top <= TEAR_TOP_MAX) {
tears[i].move_lock_unput_and_put_8(0, 0, +8, 1);
} else {
void pascal near ripple_update_and_render(
screen_x_t tear_left, screen_y_t tear_top_max, int8_t &frame
);
ripple_update_and_render(
tears[i].cur_left, TEAR_TOP_MAX, tear_anim_frame[i]
);
}
}
}
}
// Oh hey, ZUN did write the obvious wrapper function! Definitely nicer than
// the [ptn_sloppy_unput_before_alpha_put] hack, but it still suffers from the
// same word alignment bug.
void pascal near ripple_unput_and_put(
screen_x_t left, vram_y_t top, int ptn_id, int quarter
)
{
ptn_sloppy_unput_quarter_16(left, top);
ptn_put_quarter_8(left, top, ptn_id, quarter);
}
void pascal near ripple_update_and_render(
screen_x_t tear_left, screen_y_t tear_top_max, int8_t &frame
)
{
// The ripple effect relies entirely on previous cels not being unblitted,
// and the bottom 8 pixels of the C_RIPPLE_HIGH sprite being flood-filled.
// Therefore, placing each successive C_RIPPLE_HIGH sprite 8 pixels above
// the last one entirely avoids the need for C_RIPPLE_FILLED. (Using that
// sprite would have ensured the stability of the animation against an
// overlapping player Orb, though!)
// Expects [ptn_sloppy_unput_before_alpha_put] to be `true`.
#define unput_and_put(left, top, frame) { \
ptn_put_quarter_8( \
left, \
(top - ( \
(frame == 0) ? 0 : \
(frame <= 5) ? (8 * (frame - 1)) : \
(frame <= 9) ? (8 * (9 - frame)) : \
0 \
)), \
PTN_RIPPLE, \
( \
(frame == 0) ? C_RIPPLE_LOW : \
(frame <= 4) ? C_RIPPLE_HIGH : \
(frame <= 9) ? C_RIPPLE_LOW : \
C_RIPPLE_EMPTY /* effectively just unblitting the sprite */ \
) \
); \
}
#define unput_and_put_center(frame) { \
unput_and_put((tear_left + ( 0 * RIPPLE_W)), tear_top_max, frame); \
}
#define unput_and_put_inner(frame) { \
unput_and_put((tear_left + (-1 * RIPPLE_W)), tear_top_max, frame); \
unput_and_put((tear_left + (+1 * RIPPLE_W)), tear_top_max, frame); \
}
#define unput_and_put_outer(frame) { \
unput_and_put((tear_left + (-2 * RIPPLE_W)), tear_top_max, frame); \
unput_and_put((tear_left + (+2 * RIPPLE_W)), tear_top_max, frame); \
}
frame++;
// ZUN bug: Ripple sprites can be blitted to non-word-aligned X positions,
// which this hack doesn't support, as explained in its comment. Using such
// a hack in the first place only makes sense if you unblit and blit each
// ripple column individually before moving on to the next one. Which the
// code below does, by symmetrically moving out from the center to the
// left and right edges. But then, unblitting every successive ripple
// column by rounding its X coordinate down and up to the nearest word will
// also cause half of the previously drawn column to be unblitted. This is
// exactly why most ripple animations show up with weird empty 8-pixel-wide
// stripes on the inside (→ non-word-aligned X positions), while some do
// show up fine (→ word-aligned X positions).
ptn_sloppy_unput_before_alpha_put = true;
// Ultimate hardcoding! Using entities to track this state just wastes
// space, right? (Hint: It doesn't, this does.)
if(frame < 5) {
unput_and_put_center(0);
tear_ripple_hittest(tear_left, 0);
mdrv2_se_play(7);
} else if(frame < 10) {
unput_and_put_center(1);
tear_ripple_hittest(tear_left, 0);
} else if(frame < 15) {
tear_ripple_hittest((tear_left - RIPPLE_W), (RIPPLE_W * 2));
unput_and_put_center(2);
unput_and_put_inner(0);
} else if(frame < 20) {
tear_ripple_hittest((tear_left - RIPPLE_W), (RIPPLE_W * 2));
unput_and_put_center(3);
unput_and_put_inner(1);
} else if(frame < 25) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(4);
unput_and_put_inner(2);
unput_and_put_outer(0);
} else if(frame < 30) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(5);
unput_and_put_inner(3);
unput_and_put_outer(1);
} else if(frame < 35) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(6);
unput_and_put_inner(4);
unput_and_put_outer(2);
} else if(frame < 40) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(7);
unput_and_put_inner(5);
unput_and_put_outer(3);
} else if(frame < 45) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(8);
unput_and_put_inner(6);
unput_and_put_outer(4);
} else if(frame < 50) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(9);
unput_and_put_inner(7);
unput_and_put_outer(5);
} else if(frame < 55) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_center(10);
unput_and_put_inner(8);
unput_and_put_outer(6);
} else if(frame < 60) {
tear_ripple_hittest((tear_left - (RIPPLE_W * 2)), (RIPPLE_W * 4));
unput_and_put_inner(9);
unput_and_put_outer(7);
} else if(frame < 65) {
unput_and_put_inner(10);
unput_and_put_outer(8);
} else if(frame < 70) {
unput_and_put_outer(9);
} else if(frame < 75) {
unput_and_put_outer(10);
} else {
frame = 0;
}
ptn_sloppy_unput_before_alpha_put = false;
#undef unput_and_put_outer
#undef unput_and_put_inner
#undef unput_and_put_center
#undef unput_and_put
}
// Must be called for the rest of the fight after the first call in order to
// not leave inactive soul, tear, and ripple sprites in VRAM. (Thankfully, the
// original game does that.)
inline void souls_move_diagonally_and_render__tears_update_and_render(
pixel_t velocity_base_x, pixel_t velocity_base_y
) {
pixel_t velocity_x = polar_x(0, velocity_base_x, boss_phase_frame);
// Using polar_x() rather than polar_y() is what makes this movement
// diagonal instead of elliptic.
pixel_t velocity_y = polar_x(0, velocity_base_y, boss_phase_frame);
tears_update_and_render();
soul_move_and_render(0, +velocity_x, velocity_y);
soul_move_and_render(1, -velocity_x, velocity_y);
}
// What's a EGC?
void pascal near graph_copy_line_1_to_0_masked(vram_y_t y, dots16_t mask)
{
vram_offset_t vo = vram_offset_muldiv(0, y);
dots16_t p1;
if((y < 0) || (y > (RES_Y - 1))) {
return;
}
for(vram_word_amount_t word_x = 0; word_x < (ROW_SIZE / 2); word_x++) {
graph_accesspage_func(1); p1 = (peek(SEG_PLANE_B, vo) & mask);
graph_accesspage_func(0); poke2(SEG_PLANE_B, vo, p1);
graph_accesspage_func(1); p1 = (peek(SEG_PLANE_R, vo) & mask);
graph_accesspage_func(0); poke2(SEG_PLANE_R, vo, p1);
graph_accesspage_func(1); p1 = (peek(SEG_PLANE_G, vo) & mask);
graph_accesspage_func(0); poke2(SEG_PLANE_G, vo, p1);
graph_accesspage_func(1); p1 = (peek(SEG_PLANE_E, vo) & mask);
graph_accesspage_func(0); poke2(SEG_PLANE_E, vo, p1);
vo += 2;
}
}
inline void entrance_symmetric_line_1_to_0(
const vram_y_t &y, pixel_t offset, dots16_t mask
) {
graph_copy_line_1_to_0_masked(( y - offset), mask);
graph_copy_line_1_to_0_masked(((RES_Y - 1) - y + offset), mask);
}
void near pattern_symmetric_spiral_from_disc(void)
{
static unsigned char angle;
static unsigned char drift;
static pixel_t distance;
screen_x_t left;
screen_y_t top;
if(boss_phase_frame < 100) {
return;
}
if(boss_phase_frame == 100) {
angle = 0x00;
drift = 0x00;
select_for_rank(pattern_state.interval, 4, 3, 2, 1);
select_for_rank(distance,
DISC_RADIUS, DISC_RADIUS, DISC_RADIUS, ((DISC_RADIUS / 2) + 5)
);
}
if((boss_phase_frame % pattern_state.interval) == 0) {
left = polar_x(DISC_CENTER_X, distance, (angle + 0x00));
top = polar_y(DISC_CENTER_Y, distance, (angle + 0x00));
Pellets.add_single(left, top, (angle + 0x00 + drift), to_sp(3.125f));
left = polar_x(DISC_CENTER_X, distance, (angle + 0x80));
top = polar_y(DISC_CENTER_Y, distance, (angle + 0x80));
Pellets.add_single(left, top, (angle + 0x80 + drift), to_sp(3.125f));
if(boss_phase_frame >= 900) {
boss_phase_frame = 0;
}
angle -= 0x08;
drift++;
}
}
void near pattern_spinning_aimed_rings(void)
{
enum {
RING = 8,
};
screen_x_t left;
screen_y_t top;
int interval;
select_for_rank(interval, 80, 60, 55, 50);
if((boss_phase_frame % interval) != 0) {
return;
}
for(int i = 0; i < RING; i++) {
left = polar_x(
(LIGHTBALL_CENTER_X - (PELLET_W / 2)), 16, (i * (0x100 / RING))
);
top = polar_y(LIGHTBALL_CENTER_Y, 16, (i * (0x100 / RING)));
unsigned char angle = iatan2(
((player_center_y() + (PELLET_H / 2)) - LIGHTBALL_CENTER_Y),
((player_center_x() + (PELLET_W / 2)) - LIGHTBALL_CENTER_X)
);
Pellets.add_single(
left,
top,
angle,
to_sp(3.0f),
PM_SPIN,
to_sp(2.0f),
(LIGHTBALL_CENTER_X - PELLET_W),
LIGHTBALL_CENTER_Y
);
}
}
kikuri_phase_4_subphase_t near phase_4_souls_activate(void)
{
if(boss_phase_frame == 200) {
souls[0].pos_set(
(LIGHTBALL_CENTER_X - (SOUL_W / 2)),
(LIGHTBALL_CENTER_Y - (SOUL_H / 2)), 50,
SOUL_AREA_LEFT, SOUL_AREA_RIGHT, SOUL_AREA_TOP, SOUL_AREA_BOTTOM
);
souls[1].pos_set(
(LIGHTBALL_CENTER_X - (SOUL_W / 2)),
(LIGHTBALL_CENTER_Y - (SOUL_H / 2)), 50,
SOUL_AREA_LEFT, SOUL_AREA_RIGHT, SOUL_AREA_TOP, SOUL_AREA_BOTTOM
);
} else if((boss_phase_frame > 200) && ((boss_phase_frame % 2) == 0)) {
soul_move_and_render(0, -4, -3);
soul_move_and_render(1, +4, -3);
if(souls[0].cur_top < (
SOUL_AREA_TOP + playfield_fraction_y(2 / 21.0f)
)) {
return P4_PATTERN;
}
}
return P4_SOUL_ACTIVATION;
}
kikuri_phase_4_subphase_t near pattern_souls_spreads(void)
{
screen_x_t left;
screen_y_t top;
int unused;
pellet_group_t group;
soul_move_and_render(0, 0, 0);
soul_move_and_render(1, 0, 0);
select_for_rank(unused, 100, 80, 70, 60);
if((boss_phase_frame % 80) == 0) {
for(int i = 0; i < SOUL_COUNT; i++) {
left = (souls[i].cur_center_x() - (PELLET_W / 2));
top = (souls[i].cur_center_y() - (PELLET_H / 1));
select_for_rank(reinterpret_cast<int &>(group),
PG_2_SPREAD_WIDE_AIMED,
PG_3_SPREAD_WIDE_AIMED,
PG_5_SPREAD_WIDE_AIMED,
// Not aimed on Lunatic?
// (Compare pattern_bat_slow_spreads() in the Elis fight.)
PG_5_SPREAD_WIDE
);
Pellets.add_group(left, top, group, to_sp(3.4375f));
}
}
// Note how [boss_phase_frame] carries over from phase_4_souls_activate().
if(boss_phase_frame > 600) {
return P4_DONE;
}
return P4_PATTERN;
}
void near pattern_souls_drop_tears_and_move_diagonally(void)
{
if(boss_phase_frame < 10) {
select_for_rank(pattern_state.interval, 200, 160, 140, 120);
}
if((boss_phase_frame % pattern_state.interval) == 0) {
tears_add(
(souls[0].cur_center_x() - (TEAR_W / 4)), // Not half?!
(souls[0].cur_center_y() - (TEAR_H / 2))
);
tears_add(
(souls[1].cur_center_x() - (TEAR_W / 4)), // Not half?!
(souls[1].cur_center_y() - (TEAR_H / 2))
);
}
souls_move_diagonally_and_render__tears_update_and_render(6, 2);
}
#define fire_aimed_eye_laser( \
i, origin_left, aim_offset_x, speed_multiplied_by_8, w \
) { \
shootout_lasers[i].spawn( \
origin_left, \
EYE_BOTTOM, \
(player_left + aim_offset_x), \
PLAYFIELD_BOTTOM, \
speed_multiplied_by_8, \
COL_LASER, \
20, \
w \
); \
}
inline void fire_random_aimed_eye_laser(
int i,
screen_x_t origin_left,
pixel_t aim_range_x,
const int &speed_multiplied_by_8,
pixel_t w
) {
fire_aimed_eye_laser(
i,
origin_left,
((rand() % (aim_range_x * 2)) - aim_range_x),
speed_multiplied_by_8,
w
);
}
void near pattern_two_crossed_eye_lasers(void)
{
// Yup, first firing on [boss_phase_frame] 400.
if((boss_phase_frame > 200) && ((boss_phase_frame % 200) == 0)) {
enum {
LASER_W = 8,
OFFSET_X = ((PLAYFIELD_W / 10) - (LASER_W / 2)),
};
fire_aimed_eye_laser(
0, EYE_LEFT_CENTER_X, +OFFSET_X, (to_sp(6.25f) / 2), LASER_W
);
fire_aimed_eye_laser(
1, EYE_RIGHT_CENTER_X, -OFFSET_X, (to_sp(6.25f) / 2), LASER_W
);
mdrv2_se_play(6);
}
}
void near pattern_souls_single_aimed_pellet_and_move_diagonally(void)
{
if((boss_phase_frame % 200) == 0) {
Pellets.add_group(
(souls[0].cur_center_x() - (PELLET_W / 2)),
(souls[0].cur_center_y() - (PELLET_H / 2)),
PG_1_AIMED,
to_sp(2.5f)
);
Pellets.add_group(
(souls[1].cur_center_x() - (PELLET_W / 2)),
(souls[1].cur_center_y() - (PELLET_H / 2)),
PG_1_AIMED,
to_sp(2.5f)
);
}
souls_move_diagonally_and_render__tears_update_and_render(4, 2);
}
int near pattern_4_spiral_along_disc(void)
{
static unsigned char angle;
if(boss_phase_frame < 100) {
return 0;
}
if(boss_phase_frame == 100) {
angle = 0x20;
select_for_rank(pattern_state.interval, 14, 8, 6, 4);
}
if((boss_phase_frame % pattern_state.interval) == 0) {
enum {
RING = 2,
};
for(int i = 0; i < (RING * 2); i += 2) {
screen_x_t left = polar_x(
DISC_CENTER_X,
DISC_RADIUS,
((boss_phase_frame * 4) + ((0x100 / RING) * i))
);
screen_y_t top = polar_y(
DISC_CENTER_Y,
DISC_RADIUS,
((boss_phase_frame * 4) + ((0x100 / RING) * i))
);
Pellets.add_single(left, top, (angle + 0x00), to_sp(2.25f));
Pellets.add_single(left, top, (angle + 0x40), to_sp(2.25f));
Pellets.add_single(left, top, (angle + 0x80), to_sp(2.25f));
Pellets.add_single(left, top, (angle + 0xC0), to_sp(2.25f));
}
if(boss_phase_frame <= 600) {
angle -= 0x05;
} else {
boss_phase_frame = 0;
return 1;
}
}
return 0;
}
int near pattern_single_lasers_from_left_eye(void)
{
enum {
ORIGIN_X = EYE_LEFT_CENTER_X,
LASER_W = 4,
// Should *technically* subtract (LASER_W / 2), as seen in
// pattern_two_crossed_eye_lasers().
RANGE_X = ((PLAYFIELD_W / 10) - LASER_W),
};
if(boss_phase_frame <= 200) { // (redundant)
return 1;
}
if(boss_phase_frame == 250) {
select_laser_speed_for_rank(pattern_state.speed_multiplied_by_8,
6.5f, 7.0f, 7.5f, 8.0f
);
mdrv2_se_play(6);
fire_random_aimed_eye_laser(
0, ORIGIN_X, RANGE_X, pattern_state.speed_multiplied_by_8, LASER_W
);
} else if(boss_phase_frame == 280) {
mdrv2_se_play(6);
fire_random_aimed_eye_laser(
1, ORIGIN_X, RANGE_X, pattern_state.speed_multiplied_by_8, LASER_W
);
} else if(boss_phase_frame == 310) {
mdrv2_se_play(6);
fire_random_aimed_eye_laser(
2, ORIGIN_X, RANGE_X, pattern_state.speed_multiplied_by_8, LASER_W
);
} else if(boss_phase_frame == 340) {
mdrv2_se_play(6);
fire_random_aimed_eye_laser(
3, ORIGIN_X, RANGE_X, pattern_state.speed_multiplied_by_8, LASER_W
);
} else if(boss_phase_frame == 370) {
mdrv2_se_play(6);
fire_random_aimed_eye_laser(
4, ORIGIN_X, RANGE_X, pattern_state.speed_multiplied_by_8, LASER_W
);
} else if(boss_phase_frame == 400) {
mdrv2_se_play(6);
fire_random_aimed_eye_laser(
5, ORIGIN_X, RANGE_X, pattern_state.speed_multiplied_by_8, LASER_W
);
}
if(boss_phase_frame > 500) {
boss_phase_frame = 0;
return 2;
}
return 1;
}
int near pattern_souls_symmetric_rain_lines(void)
{
if(boss_phase_frame > 100) {
// Yes, this condition can never be true here. This pattern always runs
// after pattern_single_lasers_from_left_eye(), so it reuses the
// [pattern_state] of that one (a laser speed), and reinterprets it as
// an interval. Therefore, the actual values are (52, 56, 60, 64).
if(boss_phase_frame == 100) {
select_for_rank(pattern_state.interval, 35, 25, 23, 22);
}
if((boss_phase_frame % pattern_state.interval) == 0) {
for(int i = 0; i < SOUL_COUNT; i++) {
#define fire(i, angle, speed_base) { \
pellets_add_single_rain( \
(souls[i].cur_center_x() - (PELLET_W / 2)), \
(souls[i].cur_center_y() - (PELLET_H / 2)), \
angle, \
speed_base \
); \
}
fire(i, 0x00, 3.0f);
fire(i, 0x00, 2.0f);
fire(i, 0x00, 1.0f);
fire(i, 0x80, 1.0f);
fire(i, 0x80, 2.0f);
fire(i, 0x80, 3.0f);
mdrv2_se_play(7);
#undef fire
}
}
}
if(boss_phase_frame > 250) {
boss_phase_frame = 0;
return 3;
}
return 2;
}
int near pattern_vertical_lasers_from_top(void)
{
enum {
LASER_COUNT = 10,
INTERVAL = 10,
DISTANCE_X = (PLAYFIELD_W / LASER_COUNT),
KEYFRAME_START = 100,
KEYFRAME_END = (KEYFRAME_START + ((LASER_COUNT - 1) * INTERVAL)),
};
// [shootout_lasers] would be indexed out of bounds otherwise.
static_assert(LASER_COUNT <= SHOOTOUT_LASER_COUNT);
static pixel_t random_range_x_half;
if(boss_phase_frame < KEYFRAME_START) {
return 3;
}
if(boss_phase_frame == KEYFRAME_START) {
select_laser_speed_for_rank(pattern_state.speed_multiplied_by_8,
7.5f, 8.0f, 8.5f, 9.0f
);
select_for_rank(random_range_x_half,
0, (PLAYFIELD_W / 64), (PLAYFIELD_W / 40), (PLAYFIELD_W / 32)
);
}
if((boss_phase_frame % INTERVAL) == 0) {
int i = ((boss_phase_frame - KEYFRAME_START) / INTERVAL);
pixel_t random_offset_x = (
(rand() % ((random_range_x_half * 2) + 1)) - random_range_x_half
);
shootout_lasers[i].spawn(
(PLAYFIELD_LEFT + (i * DISTANCE_X + random_offset_x)),
PLAYFIELD_TOP,
(PLAYFIELD_LEFT + (i * DISTANCE_X + random_offset_x)),
PLAYFIELD_BOTTOM,
pattern_state.speed_multiplied_by_8,
COL_LASER,
20,
8
);
mdrv2_se_play(6);
}
if(boss_phase_frame >= KEYFRAME_END) {
boss_phase_frame = 0;
return 0;
}
return 3;
}
void kikuri_main(void)
{
static struct {
bool16 invincible;
int invincibility_frame;
void update_and_render(const unsigned char (&flash_colors)[4]) {
boss_hit_update_and_render(
invincibility_frame,
invincible,
boss_hp,
flash_colors,
sizeof(flash_colors),
7000,
boss_nop,
kikuri_hittest_orb()
);
}
} hit;
static pixel_t entrance_ring_radius_base;
static bool initial_hp_rendered;
static struct {
union {
kikuri_phase_4_subphase_t subphase_4;
int phase_6_pattern;
} ax;
int patterns_done;
void frame_common(void) const {
boss_phase_frame++;
hit.invincibility_frame++;
}
} phase = { P4_SOUL_ACTIVATION, 0 };
int i;
const unsigned char flash_colors[] = { 6, 11, 8, 2 };
// Entrance animation
if(boss_phase == 0) {
// Barn door transition
// --------------------
enum {
Y_END = (RES_Y / 2),
};
#define y hit.invincibility_frame
boss_phase_frame = 0;
y = 0;
hit.invincible = false;
boss_palette_snap();
// MODDERS: Loop over [flash_colors] instead.
z_palette_set_show( 2, RGB4::max(), RGB4::max(), RGB4::max());
z_palette_set_show( 6, RGB4::max(), RGB4::max(), RGB4::max());
z_palette_set_show( 8, RGB4::max(), RGB4::max(), RGB4::max());
z_palette_set_show(11, RGB4::max(), RGB4::max(), RGB4::max());
while(1) {
boss_phase_frame++;
if((boss_phase_frame % 3) == 0) {
if(y < Y_END) {
entrance_symmetric_line_1_to_0(y, 0, 0x2222); // ( * )
}
if(((boss_phase_frame * 2) > 8) && (y < (Y_END + 8))) {
entrance_symmetric_line_1_to_0(y, 8, 0xAAAA); // (* * )
}
if(((boss_phase_frame * 2) > 16) && (y < (Y_END + 16))) {
entrance_symmetric_line_1_to_0(y, 16, 0xEEEE); // (*** )
}
if(((boss_phase_frame * 2) > 24) && (y <= (Y_END + 24))) {
entrance_symmetric_line_1_to_0(y, 24, 0xFFFF); // (****)
}
y++;
if(y > (Y_END + 24)) {
hit.invincibility_frame = 0;
boss_phase_frame = 0;
break;
}
}
// Well, if we're blocking and overwriting VRAM page 0...
// Not much point in restricting these to the rows that actually
// overwrote those sprites.
ptn_put_8(player_left, player_top, PTN_MIKO_L);
ptn_put_8(orb_cur_left, orb_cur_top, PTN_ORB);
frame_delay(1);
}
#undef y
// --------------------
// Entrance rings
// --------------
while(1) {
#define frame_half boss_phase_frame
int tmp;
frame_half++;
if(entrance_rings_update_and_render(
entrance_ring_radius_base, i, tmp, frame_half, 0, 70
)) {
boss_phase = 2;
phase.patterns_done = 0;
boss_phase_frame = 0;
boss_palette_show();
initial_hp_rendered = false;
stage_palette_set(z_Palettes);
break;
}
entrance_rings_still_active:
// Huh? On 29 out of 30 frames? (And also useless: At a speed of 1
// intensity value per frame, any 4-bit source color will have been
// ramped to any 4-bit target color by the end of frame 86.)
if((frame_half > 71) && ((frame_half % 30) != 0)) {
for(i = 0; i < COMPONENT_COUNT; i++) {
// MODDERS: Loop over [flash_colors] instead.
if(boss_palette[ 2].v[i] < z_Palettes[ 2].v[i]) {
z_Palettes[ 2].v[i]--;
}
if(boss_palette[ 6].v[i] < z_Palettes[ 6].v[i]) {
z_Palettes[ 6].v[i]--;
}
if(boss_palette[ 8].v[i] < z_Palettes[ 8].v[i]) {
z_Palettes[ 8].v[i]--;
}
if(boss_palette[11].v[i] < z_Palettes[11].v[i]) {
z_Palettes[11].v[i]--;
}
}
z_palette_set_all_show(z_Palettes);
}
if(frame_half % 2) { // That's why we've renamed the variable
frame_delay(1);
}
#undef frame_half
}
// --------------
} else if(boss_phase == 2) {
hud_hp_increment_render(initial_hp_rendered, boss_hp, boss_phase_frame);
phase.frame_common();
pattern_symmetric_spiral_from_disc();
hit.update_and_render(flash_colors);
if(boss_phase_frame == 0) {
phase.patterns_done++;
}
if(!hit.invincible && (
(boss_hp <= PHASE_2_END_HP) || (phase.patterns_done >= 6)
)) {
boss_phase = 3;
boss_phase_frame = 0;
hit.invincibility_frame = 0;
phase.patterns_done = 0;
}
} else if(boss_phase == 3) {
boss_phase_frame++;
// Original color: (0xB, 0xB, 0xF)
// Target color: (0xF, 0xB, 0xA)
enum {
FADE_INTERVAL = 20,
FADE_STEPS = (0xF - 0xA),
};
if((boss_phase_frame % FADE_INTERVAL) == (FADE_INTERVAL - 1)) {
z_Palettes[COL_LIGHTBALL].c.r++;
z_Palettes[COL_LIGHTBALL].c.b--;
stage_palette[COL_LIGHTBALL].c.r++;
stage_palette[COL_LIGHTBALL].c.b--;
z_palette_set_all_show(stage_palette);
}
if(boss_phase_frame >= (FADE_INTERVAL * FADE_STEPS)) {
boss_phase = 4;
boss_phase_frame = 0;
phase.ax.subphase_4 = P4_SOUL_ACTIVATION;
z_palette_set_all_show(stage_palette); // (redundant)
boss_palette_snap();
}
} else if(boss_phase == 4) {
phase.frame_common();
pattern_spinning_aimed_rings();
if(phase.ax.subphase_4 == P4_SOUL_ACTIVATION) {
phase.ax.subphase_4 = phase_4_souls_activate();
} else {
phase.ax.subphase_4 = pattern_souls_spreads();
}
hit.update_and_render(flash_colors);
if(!hit.invincible && (phase.ax.subphase_4 == P4_DONE)) {
boss_phase = 5;
// Should be done during phase 6 initialization for clarity.
phase.ax.phase_6_pattern = 0;
boss_phase_frame = 0;
}
} else if(boss_phase == 5) {
phase.frame_common();
pattern_souls_drop_tears_and_move_diagonally();
pattern_two_crossed_eye_lasers();
hit.update_and_render(flash_colors);
if(!hit.invincible && (
(boss_hp <= PHASE_5_END_HP) || (boss_phase_frame > 1600)
)) {
boss_phase = 6;
boss_phase_frame = 0;
hit.invincibility_frame = 0;
phase.patterns_done = 0;
}
} else if(boss_phase == 6) {
phase.frame_common();
pattern_souls_single_aimed_pellet_and_move_diagonally();
if(phase.ax.phase_6_pattern == 0) {
phase.ax.phase_6_pattern = pattern_4_spiral_along_disc();
} else if(phase.ax.phase_6_pattern == 1) {
phase.ax.phase_6_pattern = pattern_single_lasers_from_left_eye();
} else if(phase.ax.phase_6_pattern == 2) {
phase.ax.phase_6_pattern = pattern_souls_symmetric_rain_lines();
} else if(phase.ax.phase_6_pattern == 3) {
phase.ax.phase_6_pattern = pattern_vertical_lasers_from_top();
}
if(boss_phase_frame == 0) {
phase.patterns_done++;
}
// That's a well-hidden timeout condition! Especially since there is no
// corresponding HP rendering call to reflect this change in the HUD.
if(phase.patterns_done > 29) {
boss_hp = 1;
}
hit.update_and_render(flash_colors);
if(boss_hp <= 0) {
mdrv2_bgm_fade_out_nonblock();
Pellets.unput_and_reset();
shootout_lasers_unput_and_reset_broken(i, 4); // 4? Doubly broken...
boss_defeat_animate();
scene_init_and_load(6);
}
}
}