The easiest solution is probably to use BulletML. BulletML is a
godawful domain-specific language for writing bullet hell patterns. It is a little limited, in that you can't do things like bouncing off the edge of the screen or anchoring some bullet to another, and the BulletML script itself will have no concept of the size or type of bullet. You can get BulletML
here. Here's
some screenies of a game I scripted in BulletML forever ago.
sdmkun and
rRootage are awesome games scripted with BulletML, so you can open them up and look at their guts if you want.
If BulletML does not sound good, and you want to write your bullet patterns in the project's native language, you can use lightweight threads. If you have the ability to choose your JVM, the Da Vinci and Avian VMs expose coroutines or the right tools to build them (presumably you can also find libraries that have actually built them, idk). If you have the ability to choose your language, Scala's delimited continuations will give you the same thing on any JVM, and
this seems like an acceptable coroutine library.
If you don't want to embed some other language in the project and none of the ways of getting lightweight threads on the JVM are acceptable, you'll probably have to phrase all your bullet patterns as functions that can be called once each frame. I'll provide a couple examples of how to do this, using
bullet patterns from something I abandoned.
These two patterns look a lot like
ZUN's shit.
function border_of_wave_and_particle(self)
local theta = 0
local dtheta = 0
local ddtheta = 0.2 * degrees
self.child_color = colors.purple
while true do
local n = floor(3+2*rank)
for i=tau/n, tau, tau/n do
self:fire({speed=8, direction=theta+i})
end
dtheta = dtheta + ddtheta
theta = (theta + dtheta) % tau
self:wait(2)
end
end
would become
function gen_border_of_wave_and_particle()
local theta = 0
local dtheta = 0
local ddtheta = 0.2 * degrees
local age = 0
return function(self)
if age == 0 then
self.child_color = colors.purple
end
if age % 2 == 0 then
local n = floor(3+2*rank)
for i=tau/n, tau, tau/n do
self:fire({speed=8, direction=theta+i})
end
dtheta = dtheta + ddtheta
theta = (theta + dtheta) % tau
end
age = age + 1
end
end
and
function mokou_197(self)
local bouncer = function(self) while true do
self:wait(1)
if self.y < 0 or self.y > 600 then
self.child_color = colors.blue
self:fire({speed=self.speed/2, direction=self.direction+pi})
return
end
end end
local spawner = function(self) while true do
self:wait(20)
self.child_color = colors.pink
self:fire({speed=4, direction=facing_up}, bouncer)
self:fire({speed=4, direction=facing_down}, bouncer)
end end
while true do
self.child_kind = "spawner"
self.child_color = nil
self:fire({speed=1.25, direction=facing_right}, spawner)
self:fire({speed=1.25, direction=facing_left}, spawner)
self.child_kind = "enemy_bullet"
self.child_color = colors.pink
self:fire({speed=4, direction=facing_up}, bouncer)
self:fire({speed=4, direction=facing_down}, bouncer)
self:wait(180 - min(45*rank,120))
end
end
would become
function gen_mokou_197_bouncer()
local fired = false
return function(self)
-- technically this is wrong, the original code does not check on the first frame...
-- that really doesn't matter though
if not fired and (self.y < 0 or self.y > 600) then
self.child_color = colors.blue
self:fire({speed=self.speed/2, direction=self.direction+pi})
fired = true
end
end
end
function gen_mokou_197_spawner()
local age = 0
return function(self)
if age>0 and age%20==0 then
self.child_color = colors.pink
self:fire({speed=4, direction=facing_up}, gen_mokou_197_bouncer())
self:fire({speed=4, direction=facing_down}, gen_mokou_197_bouncer())
end
age = age + 1
end
end
function gen_mokou_197()
local timeout = 1
local timer = 0
return function(self)
timer = timer + 1
if timer >= timeout then
self.child_kind = "spawner"
self.child_color = nil
self:fire({speed=1.25, direction=facing_right}, gen_mokou_197_spawner())
self:fire({speed=1.25, direction=facing_left}, gen_mokou_197_spawner())
self.child_kind = "enemy_bullet"
self.child_color = colors.pink
self:fire({speed=4, direction=facing_up}, gen_mokou_197_bouncer())
self:fire({speed=4, direction=facing_down}, gen_mokou_197_bouncer())
timer = 0
timeout = floor(180 - min(45*rank,120))
end
end
end
You would call gen_* to get the function that you would then call each frame on a particular enemy (probably Yukari, Satori, or
mai waifu). To do this sort of thing in Java, the gen_* function would be a class, the closed-over locals would be class members, and the returned function would be a method. Each in-game entity could own a BulletAction[], and once per frame you can go through each object and call all of its BulletActions. Apparently I decided to do this after moving everything and before collision detection, but I don't really remember why.
At the last minute I realized that it may be possible to address the problem by doing nothing. Lots of shmups have so few entities that need to run their own particular code that you really could attach threads to each of them and be fine. For example, in
DoDonPachi, none of the enemy projectiles ever change their direction or velocity or spawn other projectiles. One thread per on-screen enemy would be like 30 threads at the very most.