changeset 220:0595315d3880

Fix SHT handling; change a few things to be closer to ZUN’s mind; and first stub of PCB support.
author Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
date Sun, 18 Dec 2011 14:14:32 +0100
parents 091301805cce
children 5c3600e0f0cd
files pcb pytouhou/formats/sht.py pytouhou/game/bullet.py pytouhou/game/bullettype.py pytouhou/game/enemy.py pytouhou/game/game.py pytouhou/game/item.py pytouhou/game/player.py pytouhou/games/eosd.py pytouhou/games/pcb.py pytouhou/resource/loader.py
diffstat 11 files changed, 272 insertions(+), 65 deletions(-) [+]
line wrap: on
line diff
new file mode 100755
--- /dev/null
+++ b/pcb
@@ -0,0 +1,59 @@
+#!/usr/bin/env python
+# -*- encoding: utf-8 -*-
+##
+## Copyright (C) 2011 Thibaut Girka <thib@sitedethib.com>
+##
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published
+## by the Free Software Foundation; version 3 only.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+
+import argparse
+import os
+
+import pyximport
+pyximport.install()
+
+from pytouhou.resource.loader import Loader
+from pytouhou.game.background import Background
+from pytouhou.ui.gamerunner import GameRunner
+from pytouhou.games.pcb import PCBGame
+from pytouhou.game.player import PlayerState
+
+
+def main(path, stage_num, rank, character, data):
+    resource_loader = Loader()
+    resource_loader.scan_archives(os.path.join(path, name)
+                                    for name in data)
+    game = PCBGame(resource_loader, [PlayerState(character=character)], stage_num, rank, 16)
+
+    # Load stage data
+    stage = resource_loader.get_stage('stage%d.std' % stage_num)
+
+    background_anm_wrapper = resource_loader.get_anm_wrapper(('stg%dbg.anm' % stage_num,))
+    background = Background(stage, background_anm_wrapper)
+
+    # Let's go!
+    print(stage.name)
+
+    # Main loop
+    runner = GameRunner(resource_loader, game, background)
+    runner.start()
+
+
+parser = argparse.ArgumentParser(description='Libre reimplementation of the Touhou 6 engine.')
+
+parser.add_argument('data', metavar='DAT', default=('Th07.dat'), nargs='*', help='Game’s .DAT data files')
+parser.add_argument('-p', '--path', metavar='DIRECTORY', default='.', help='Game directory path.')
+parser.add_argument('-s', '--stage', metavar='STAGE', type=int, required=True, help='Stage, 1 to 6, 7 (Extra) and 8 (Phantasm).')
+parser.add_argument('-r', '--rank', metavar='RANK', type=int, default=0, help='Rank, from 0 (Easy, default) to 3 (Lunatic).')
+parser.add_argument('-c', '--character', metavar='CHARACTER', type=int, default=0, help='Select the character to use, from 0 (ReimuA, default) to 5 (SakuyaB).')
+
+args = parser.parse_args()
+
+main(args.path, args.stage, args.rank, args.character, tuple(args.data))
--- a/pytouhou/formats/sht.py
+++ b/pytouhou/formats/sht.py
@@ -21,7 +21,7 @@ logger = get_logger(__name__)
 
 class Shot(object):
     def __init__(self):
-        self.interval = 0.
+        self.interval = 0
         self.unknown1 = None
         self.pos = (0., 0.)
         self.hitbox = (0., 0.)
@@ -41,12 +41,11 @@ class Shot(object):
 class SHT(object):
     def __init__(self):
         self.unknown1 = None
-        self.level_count = 9
         self.bombs = 0.
         self.unknown2 = None
         self.hitbox = 0.
         self.graze_hitbox = 0.
-        self.autocollected_item_speed = 0.
+        self.autocollection_speed = 0.
         self.item_hitbox = 0.
         self.percentage_of_cherry_loss_on_die = 0.
         self.point_of_collection = 0
@@ -59,18 +58,20 @@ class SHT(object):
 
     @classmethod
     def read(cls, file):
-        (_, level_count, bombs, _, hitbox, graze_hitbox,
-         autocollected_item_speed, item_hitbox, percentage_of_cherry_loss_on_die,
-         point_of_collection, horizontal_vertical_speed,
-         horizontal_vertical_focused_speed, diagonal_speed,
-         diagonal_focused_speed) = unpack('<hhfI10f', file.read(52))
+        sht = cls()
+
+        data = unpack('<hhfI10f', file.read(52))
+        (sht.unknown1, level_count, sht.bombs, sht.unknown2, sht.hitbox,
+         sht.graze_hitbox, sht.autocollection_speed, sht.item_hitbox,
+         sht.percentage_of_cherry_loss_on_die, sht.point_of_collection,
+         sht.horizontal_vertical_speed, sht.horizontal_vertical_focused_speed,
+         sht.diagonal_speed, sht.diagonal_focused_speed) = data
 
         levels = []
         for i in xrange(level_count):
             offset, power = unpack('<II', file.read(8))
             levels.append((power, offset))
 
-        sht = cls()
         sht.shots = {}
 
         for power, offset in levels:
@@ -84,20 +85,19 @@ class SHT(object):
 
                 shot = Shot()
 
-                data = file.read(48)
+                shot.interval = interval
+                shot.unknown1 = unknown1
+
+                data = unpack('<6fHBBhh4I', file.read(48))
                 (x, y, hitbox_x, hitbox_y, shot.angle, shot.speed,
                  shot.damage, shot.orb, shot.unknown2, shot.sprite,
-                 shot.unknown3, unknown4, homing, unknown5,
-                 unknown6) = unpack('<6fHBBhh4I', data)
+                 shot.unknown3, shot.unknown4, shot.homing, shot.unknown5,
+                 shot.unknown6) = data
 
                 shot.pos = (x, y)
                 shot.hitbox = (hitbox_x, hitbox_y)
-                shot.unknown4 = bool(unknown4)
-                shot.homing = bool(homing)
-                shot.unknown5 = bool(unknown5)
-                shot.unknown6 = bool(unknown6)
 
-                sht.shts[power].append(shot)
+                sht.shots[power].append(shot)
 
 
         return sht
--- a/pytouhou/game/bullet.py
+++ b/pytouhou/game/bullet.py
@@ -21,7 +21,8 @@ from pytouhou.game.sprite import Sprite
 
 class Bullet(object):
     def __init__(self, pos, bullet_type, sprite_idx_offset,
-                       angle, speed, attributes, flags, player, game, player_bullet=False):
+                       angle, speed, attributes, flags, player, game,
+                       player_bullet=False, damage=0, hitbox=None):
         self._game = game
         self._sprite = None
         self._anmrunner = None
@@ -29,7 +30,10 @@ class Bullet(object):
         self._launched = False
         self._bullet_type = bullet_type
 
-        self.hitbox_half_size = bullet_type.hitbox_size / 2.
+        if hitbox:
+            self.hitbox_half_size = (hitbox[0] / 2., hitbox[1] / 2.)
+        else:
+            self.hitbox_half_size = (bullet_type.hitbox_size / 2., bullet_type.hitbox_size / 2.)
 
         self.speed_interpolator = None
         self.frame = 0
@@ -49,6 +53,7 @@ class Bullet(object):
         self.delta = dx, dy
 
         self.player_bullet = player_bullet
+        self.damage = damage
 
         #TODO
         if flags & 14:
--- a/pytouhou/game/bullettype.py
+++ b/pytouhou/game/bullettype.py
@@ -3,8 +3,7 @@ class BulletType(object):
                  launch_anim2_index, launch_anim4_index, launch_anim8_index,
                  hitbox_size,
                  launch_anim_penalties=(0.5, 0.4, 1./3.),
-                 launch_anim_offsets=(0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 4, 4, 4, 0),
-                 damage=0):
+                 launch_anim_offsets=(0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 4, 4, 4, 0)):
         self.anm_wrapper = anm_wrapper
         self.anim_index = anim_index
         self.cancel_anim_index = cancel_anim_index
@@ -14,5 +13,4 @@ class BulletType(object):
         self.hitbox_size = hitbox_size
         self.launch_anim_penalties = launch_anim_penalties
         self.launch_anim_offsets = launch_anim_offsets
-        self.damage = damage
 
--- a/pytouhou/game/enemy.py
+++ b/pytouhou/game/enemy.py
@@ -241,13 +241,13 @@ class Enemy(object):
         for bullet in self._game.players_bullets:
             half_size = bullet.hitbox_half_size
             bx, by = bullet.x, bullet.y
-            bx1, bx2 = bx - half_size, bx + half_size
-            by1, by2 = by - half_size, by + half_size
+            bx1, bx2 = bx - half_size[0], bx + half_size[0]
+            by1, by2 = by - half_size[1], by + half_size[1]
 
             if not (bx2 < ex1 or bx1 > ex2
                     or by2 < ey1 or by1 > ey2):
                 bullet.collide()
-                damages += bullet._bullet_type.damage
+                damages += bullet.damage
                 self.drop_particles(1, 1)
 
         # Check for enemy-player collisions
--- a/pytouhou/game/game.py
+++ b/pytouhou/game/game.py
@@ -216,8 +216,8 @@ class Game(object):
             for bullet in self.bullets:
                 half_size = bullet.hitbox_half_size
                 bx, by = bullet.x, bullet.y
-                bx1, bx2 = bx - half_size, bx + half_size
-                by1, by2 = by - half_size, by + half_size
+                bx1, bx2 = bx - half_size[0], bx + half_size[0]
+                by1, by2 = by - half_size[1], by + half_size[1]
 
                 if not (bx2 < px1 or bx1 > px2
                         or by2 < py1 or by1 > py2):
@@ -247,7 +247,7 @@ class Game(object):
 
                 if not (bx2 < px1 or bx1 > px2
                         or by2 < py1 or by1 > py2):
-                    item.on_collect(player.state)
+                    item.on_collect(player)
 
 
     def cleanup(self):
--- a/pytouhou/game/item.py
+++ b/pytouhou/game/item.py
@@ -19,7 +19,7 @@ from pytouhou.utils.interpolator import 
 
 
 class Item(object):
-    def __init__(self, start_pos, _type, item_type, game, angle=pi/2, speed=8., player=None, end_pos=None):
+    def __init__(self, start_pos, _type, item_type, game, angle=pi/2, player=None, end_pos=None):
         self._game = game
         self._sprite = item_type.sprite
         self._removed = False
@@ -29,14 +29,13 @@ class Item(object):
         self.hitbox_half_size = item_type.hitbox_size / 2.
 
         self.frame = 0
-
-        self.player = player
-
         self.x, self.y = start_pos
         self.angle = angle
-        self.speed = speed
-        dx, dy = cos(angle) * speed, sin(angle) * speed
-        self.delta = dx, dy
+
+        if player:
+            self.autocollect(player)
+        else:
+            self.player = None
 
         if not player:
             #TODO: find the formulae in the binary.
@@ -53,10 +52,11 @@ class Item(object):
 
     def autocollect(self, player):
         self.player = player
-        self.speed = 8.
+        self.speed = player.sht.autocollection_speed if hasattr(player, 'sht') else 8.
 
 
-    def on_collect(self, player_state):
+    def on_collect(self, player):
+        player_state = player.state
         old_power = player_state.power
 
         if self._type == 0 or self._type == 2: # power or big power
@@ -84,7 +84,8 @@ class Item(object):
 
         elif self._type == 1: # point
             player_state.points += 1
-            if player_state.y < 128: #TODO: find the exact poc.
+            poc = player.sht.point_of_collection if hasattr(player, 'sht') else 128 #TODO: find the exact poc in EoSD.
+            if player_state.y < poc:
                 score = 100000
                 self._game.modify_difficulty(+30)
             else:
@@ -117,8 +118,6 @@ class Item(object):
 
 
     def update(self):
-        dx, dy = self.delta
-
         if self.frame == 60:
             self.speed_interpolator = Interpolator((0.,), 60,
                                                    (3.,), 180)
@@ -134,7 +133,6 @@ class Item(object):
             self.speed_interpolator.update(self.frame)
             self.speed, = self.speed_interpolator.values
             dx, dy = cos(self.angle) * self.speed, sin(self.angle) * self.speed
-            self.delta = dx, dy
             self.x += dx
             self.y += dy
 
--- a/pytouhou/game/player.py
+++ b/pytouhou/game/player.py
@@ -20,9 +20,6 @@ from pytouhou.game.bullettype import Bul
 from math import pi
 
 
-SQ2 = 2. ** 0.5 / 2.
-
-
 class PlayerState(object):
     def __init__(self, character=0, score=0, power=0, lives=0, bombs=0):
         self.character = character # ReimuA/ReimuB/MarisaA/MarisaB/...
@@ -46,21 +43,20 @@ class PlayerState(object):
 
 
 class Player(object):
-    def __init__(self, state, game, anm_wrapper, speed=4., hitbox_size=2.5, graze_hitbox_size=42.):
+    def __init__(self, state, game, anm_wrapper, hitbox_size=2.5, graze_hitbox_size=42., speeds=None):
         self._sprite = None
         self._anmrunner = None
         self._game = game
         self.anm_wrapper = anm_wrapper
 
-        self.speed = speed
-        self.focused_speed = speed/2.
+        self.speeds = speeds
 
         self.hitbox_size = hitbox_size
         self.hitbox_half_size = self.hitbox_size / 2.
         self.graze_hitbox_size = graze_hitbox_size
         self.graze_hitbox_half_size = self.graze_hitbox_size / 2.
 
-        self.bullet_type = BulletType(anm_wrapper, 64, 96, 0, 0, 0, hitbox_size=4, damage=48)
+        self.bullet_type = BulletType(anm_wrapper, 64, 96, 0, 0, 0, hitbox_size=4)
         self.bullet_launch_interval = 5
         self.bullet_speed = 12.
         self.bullet_launch_angle = -pi/2
@@ -113,16 +109,13 @@ class Player(object):
 
     def update(self, keystate):
         if self.death_time == 0 or self._game.frame - self.death_time > 60:
+            speed, diag_speed = self.speeds[2:] if self.state.focused else self.speeds[:2]
             try:
-                dx, dy = {16: (0.0, -1.0), 32: (0.0, 1.0), 64: (-1.0, 0.0), 128: (1.0, 0.0),
-                          16|64: (-SQ2, -SQ2), 16|128: (SQ2, -SQ2),
-                          32|64: (-SQ2, SQ2), 32|128:  (SQ2, SQ2)}[keystate & (16|32|64|128)]
+                dx, dy = {16: (0.0, -speed), 32: (0.0, speed), 64: (-speed, 0.0), 128: (speed, 0.0),
+                          16|64: (-diag_speed, -diag_speed), 16|128: (diag_speed, -diag_speed),
+                          32|64: (-diag_speed, diag_speed), 32|128:  (diag_speed, diag_speed)}[keystate & (16|32|64|128)]
             except KeyError:
-                speed = 0.0
                 dx, dy = 0.0, 0.0
-            else:
-                speed = self.focused_speed if keystate & 4 else self.speed
-                dx, dy = dx * speed, dy * speed
 
             if dx < 0 and self.direction != -1:
                 self.set_anim(1)
--- a/pytouhou/games/eosd.py
+++ b/pytouhou/games/eosd.py
@@ -24,6 +24,9 @@ from pytouhou.game.orb import Orb
 from math import pi
 
 
+SQ2 = 2. ** 0.5 / 2.
+
+
 class EoSDGame(Game):
     def __init__(self, resource_loader, player_states, stage, rank, difficulty, **kwargs):
         etama3 = resource_loader.get_anm_wrapper(('etama3.anm',))
@@ -58,8 +61,8 @@ class EoSDGame(Game):
 
 
 class EoSDPlayer(Player):
-    def __init__(self, state, game, anm_wrapper, speed=4., hitbox_size=2.5, graze_hitbox_size=42.):
-        Player.__init__(self, state, game, anm_wrapper, speed=speed)
+    def __init__(self, state, game, anm_wrapper, speeds=None, hitbox_size=2.5, graze_hitbox_size=42.):
+        Player.__init__(self, state, game, anm_wrapper, speeds=speeds)
 
         self.orbs = [Orb(self.anm_wrapper, 128, self.state, self.orb_fire),
                      Orb(self.anm_wrapper, 129, self.state, self.orb_fire)]
@@ -122,7 +125,7 @@ class Reimu(EoSDPlayer):
         anm_wrapper = resource_loader.get_anm_wrapper(('player00.anm',))
         self.bullet_angle = pi/30 #TODO: check
 
-        EoSDPlayer.__init__(self, state, game, anm_wrapper, speed=4.)
+        EoSDPlayer.__init__(self, state, game, anm_wrapper, speeds=(4., 4. * SQ2, 2., 2. * SQ2))
 
 
     def fire(self):
@@ -149,7 +152,7 @@ class Reimu(EoSDPlayer):
                 bullets.append(Bullet((self.x, self.y), self.bullet_type, 0,
                                       bullet_angle, self.bullet_speed,
                                       (0, 0, 0, 0, 0., 0., 0., 0.),
-                                      0, self, self._game, player_bullet=True))
+                                      0, self, self._game, damage=48, player_bullet=True))
                 bullet_angle += self.bullet_angle
 
         for orb in self.orbs:
@@ -161,7 +164,7 @@ class ReimuA(Reimu):
     def __init__(self, state, game, resource_loader):
         Reimu.__init__(self, state, game, resource_loader)
 
-        self.bulletA_type = BulletType(self.anm_wrapper, 65, 97, 0, 0, 0, hitbox_size=4, damage=14) #TODO: verify the hitbox.
+        self.bulletA_type = BulletType(self.anm_wrapper, 65, 97, 0, 0, 0, hitbox_size=4) #TODO: verify the hitbox, damage is 14.
         self.bulletA_speed = 12.
 
 
@@ -180,7 +183,7 @@ class ReimuB(Reimu):
     def __init__(self, state, game, resource_loader):
         Reimu.__init__(self, state, game, resource_loader)
 
-        self.bulletB_type = BulletType(self.anm_wrapper, 66, 98, 0, 0, 0, hitbox_size=4, damage=12) #TODO: verify the hitbox.
+        self.bulletB_type = BulletType(self.anm_wrapper, 66, 98, 0, 0, 0, hitbox_size=4) #TODO: verify the hitbox.
         self.bulletB_speed = 22.
 
 
@@ -194,7 +197,7 @@ class ReimuB(Reimu):
         bullets.append(Bullet((orb.x + offset_x, orb.y), self.bulletB_type, 0,
                               self.bullet_launch_angle, self.bulletB_speed,
                               (0, 0, 0, 0, 0., 0., 0., 0.),
-                              0, self, self._game, player_bullet=True))
+                              0, self, self._game, damage=12, player_bullet=True))
 
     def orb_fire(self, orb):
         if self.state.power < 8:
@@ -240,7 +243,7 @@ class Marisa(EoSDPlayer):
         anm_wrapper = resource_loader.get_anm_wrapper(('player01.anm',))
         self.bullet_angle = pi/40 #TODO: check
 
-        EoSDPlayer.__init__(self, state, game, anm_wrapper, speed=5.)
+        EoSDPlayer.__init__(self, state, game, anm_wrapper, speeds=(5., 5. * SQ2, 2.5, 2.5 * SQ2))
 
 
     def fire(self):
@@ -265,7 +268,7 @@ class Marisa(EoSDPlayer):
                 bullets.append(Bullet((self.x, self.y), self.bullet_type, 0,
                                       bullet_angle, self.bullet_speed,
                                       (0, 0, 0, 0, 0., 0., 0., 0.),
-                                      0, self, self._game, player_bullet=True))
+                                      0, self, self._game, damage=48, player_bullet=True))
                 bullet_angle += self.bullet_angle
 
 
@@ -275,7 +278,7 @@ class MarisaA(Marisa):
         Marisa.__init__(self, state, game, resource_loader)
 
         #TODO: verify the hitbox and damages.
-        self.bulletA_types = [BulletType(self.anm_wrapper, 65, 0, 0, 0, 0, hitbox_size=4, damage=40),
+        self.bulletA_types = [BulletType(self.anm_wrapper, 65, 0, 0, 0, 0, hitbox_size=4), # damage is 40.
                               BulletType(self.anm_wrapper, 66, 0, 0, 0, 0, hitbox_size=4),
                               BulletType(self.anm_wrapper, 67, 0, 0, 0, 0, hitbox_size=4),
                               BulletType(self.anm_wrapper, 68, 0, 0, 0, 0, hitbox_size=4)]
new file mode 100644
--- /dev/null
+++ b/pytouhou/games/pcb.py
@@ -0,0 +1,142 @@
+# -*- encoding: utf-8 -*-
+##
+## Copyright (C) 2011 Emmanuel Gil Peyrot <linkmauve@linkmauve.fr>
+##
+## This program is free software; you can redistribute it and/or modify
+## it under the terms of the GNU General Public License as published
+## by the Free Software Foundation; version 3 only.
+##
+## This program is distributed in the hope that it will be useful,
+## but WITHOUT ANY WARRANTY; without even the implied warranty of
+## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+## GNU General Public License for more details.
+##
+
+from pytouhou.utils.interpolator import Interpolator
+
+from pytouhou.game.game import Game
+from pytouhou.game.bullettype import BulletType
+from pytouhou.game.itemtype import ItemType
+from pytouhou.game.player import Player
+from pytouhou.game.bullet import Bullet
+from pytouhou.game.orb import Orb
+
+from math import pi
+
+
+class PCBGame(Game):
+    def __init__(self, resource_loader, player_states, stage, rank, difficulty, **kwargs):
+        etama3 = resource_loader.get_anm_wrapper(('etama3.anm',))
+        etama4 = resource_loader.get_anm_wrapper(('etama4.anm',))
+        bullet_types = [BulletType(etama3, 0, 11, 14, 15, 16, hitbox_size=4),
+                        BulletType(etama3, 1, 12, 17, 18, 19, hitbox_size=6),
+                        BulletType(etama3, 2, 12, 17, 18, 19, hitbox_size=4),
+                        BulletType(etama3, 3, 12, 17, 18, 19, hitbox_size=6),
+                        BulletType(etama3, 4, 12, 17, 18, 19, hitbox_size=5),
+                        BulletType(etama3, 5, 12, 17, 18, 19, hitbox_size=4),
+                        BulletType(etama3, 6, 13, 20, 20, 20, hitbox_size=16),
+                        BulletType(etama3, 7, 13, 20, 20, 20, hitbox_size=11),
+                        BulletType(etama3, 8, 13, 20, 20, 20, hitbox_size=9),
+                        BulletType(etama4, 0, 1, 2, 2, 2, hitbox_size=32)]
+        #TODO: hitbox
+        item_types = [ItemType(etama3, 0, 7), #Power
+                      ItemType(etama3, 1, 8), #Point
+                      ItemType(etama3, 2, 9), #Big power
+                      ItemType(etama3, 3, 10), #Bomb
+                      ItemType(etama3, 4, 11), #Full power
+                      ItemType(etama3, 5, 12), #1up
+                      ItemType(etama3, 6, 13)] #Star
+
+        players = []
+        for player in player_states:
+            players.append(PCBPlayer(player, self, resource_loader))
+
+        Game.__init__(self, resource_loader, players, stage, rank, difficulty,
+                      bullet_types, item_types, nb_bullets_max=640, **kwargs)
+
+
+
+class PCBPlayer(Player):
+    def __init__(self, state, game, resource_loader, speed=4., hitbox_size=2.5, graze_hitbox_size=42.):
+        number = '%d%s' % (state.character // 2, 'b' if state.character % 2 else 'a')
+        self.sht = resource_loader.get_sht('ply0%s.sht' % number)
+        self.focused_sht = resource_loader.get_sht('ply0%ss.sht' % number)
+        anm_wrapper = resource_loader.get_anm_wrapper(('player0%d.anm' % (state.character // 2),))
+
+        Player.__init__(self, state, game, anm_wrapper,
+                        speeds=(self.sht.horizontal_vertical_speed,
+                                self.sht.diagonal_speed,
+                                self.sht.horizontal_vertical_focused_speed,
+                                self.sht.diagonal_focused_speed))
+
+        self.orbs = [Orb(self.anm_wrapper, 128, self.state, None),
+                     Orb(self.anm_wrapper, 129, self.state, None)]
+
+        self.orbs[0].offset_x = -24
+        self.orbs[1].offset_x = 24
+
+        self.orb_dx_interpolator = None
+        self.orb_dy_interpolator = None
+
+
+    def start_focusing(self):
+        self.orb_dx_interpolator = Interpolator((24,), self._game.frame,
+                                                (8,), self._game.frame + 8,
+                                                lambda x: x ** 2)
+        self.orb_dy_interpolator = Interpolator((0,), self._game.frame,
+                                                (-32,), self._game.frame + 8)
+        self.state.focused = True
+
+
+    def stop_focusing(self):
+        self.orb_dx_interpolator = Interpolator((8,), self._game.frame,
+                                                (24,), self._game.frame + 8,
+                                                lambda x: x ** 2)
+        self.orb_dy_interpolator = Interpolator((-32,), self._game.frame,
+                                                (0,), self._game.frame + 8)
+        self.state.focused = False
+
+
+    def objects(self):
+        return self.orbs
+
+
+    def update(self, keystate):
+        Player.update(self, keystate)
+
+        if self.death_time == 0 or self._game.frame - self.death_time > 60:
+            if self.orb_dx_interpolator:
+                self.orb_dx_interpolator.update(self._game.frame)
+                dx, = self.orb_dx_interpolator.values
+                self.orbs[0].offset_x = -dx
+                self.orbs[1].offset_x = dx
+            if self.orb_dy_interpolator:
+                self.orb_dy_interpolator.update(self._game.frame)
+                dy, = self.orb_dy_interpolator.values
+                self.orbs[0].offset_y = dy
+                self.orbs[1].offset_y = dy
+
+        for orb in self.orbs:
+            orb.update()
+
+
+    def fire(self):
+        sht = self.focused_sht if self.state.focused else self.sht
+        power = min(power for power in sht.shots if self.state.power < power)
+
+        bullets = self._game.players_bullets
+        nb_bullets_max = self._game.nb_bullets_max
+
+        for shot in sht.shots[power]:
+            if self.fire_time % shot.interval == 0:
+                if nb_bullets_max is not None and len(bullets) == nb_bullets_max:
+                    break
+
+                origin = self.orbs[shot.orb - 1] if shot.orb else self
+                x = origin.x + shot.pos[0]
+                y = origin.y + shot.pos[1]
+
+                bullets.append(Bullet((x, y), self.bullet_type, 0,
+                                      shot.angle, shot.speed,
+                                      (0, 0, 0, 0, 0., 0., 0., 0.),
+                                      0, self, self._game, player_bullet=True, damage=shot.damage, hitbox=shot.hitbox))
--- a/pytouhou/resource/loader.py
+++ b/pytouhou/resource/loader.py
@@ -5,6 +5,7 @@ from pytouhou.formats.std import Stage
 from pytouhou.formats.ecl import ECL
 from pytouhou.formats.anm0 import Animations
 from pytouhou.formats.msg import MSG
+from pytouhou.formats.sht import SHT
 
 
 from pytouhou.resource.anmwrapper import AnmWrapper
@@ -44,6 +45,7 @@ class Loader(object):
         self.instanced_anms = {}
         self.instanced_stages = {}
         self.instanced_msgs = {}
+        self.instanced_shts = {}
 
 
     def scan_archives(self, paths):
@@ -93,6 +95,13 @@ class Loader(object):
         return self.instanced_msgs[name]
 
 
+    def get_sht(self, name):
+        if name not in self.instanced_shts:
+            file = self.get_file(name)
+            self.instanced_shts[name] = SHT.read(file) #TODO: modular
+        return self.instanced_shts[name]
+
+
     def get_anm_wrapper(self, names):
         return AnmWrapper(self.get_anm(name) for name in names)