Pythonista+sceneで画面スクロールを作成する

今回は、Pythonista+sceneで2Dアクションのスクロール処理を作成してみました。

## プログラム概要
キャラクターが移動したら画面をスクロールする。画面の端に移動したらスクロールをやめて自由に移動できるようにする。

## 実装した処理
実装した処理は以下の2つです。
・画面スクロール
・画面端の処理

### 画面スクロール
キャラクターが移動したら画面をスクロールする処理です。
スクロール中はキャラクターの位置はそのままで、背景やアイテムをキャラクターの移動と逆方向に移動することでスクロールを表現します。

![Pythonista_scroll.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415049/61daa51a-ffeb-5bdf-c4bd-d426e5f6ad63.png)

コードは以下の通り。update()メソッドに追記します。

```python:

def update(self):
	g = gravity()
	x = self.player.position.x
	y = self.player.position.y
	# 左右の移動
	if abs(g.x) > 0.05:
		max_speed = 40
		self.update_position(max_speed)
	else:
		self.player.texture = standing_texture
		self.walk_step = -1
		
def update_position(self, max_speed):
	g = gravity()
	x = self.player.position.x
	y = self.player.position.y
	distance = g.x * max_speed 
		
	x = self.size.w/2
	self.player.position = (x, y)
	for t in self.tiles:
		t['tile1'].position = (t['tile1'].position.x - distance, t['tile1'].position.y)
		t['tile2'].position = (t['tile2'].position.x - distance, t['tile2'].position.y)
	for item in self.items:
		item.position = (item.position.x - distance, item.position.y)
	for item in self.stars:
		item.position = (item.position.x - distance, item.position.y)
	for item in self.mushrooms:
		item.position = (item.position.x - distance, item.position.y)
```

### 画面端の処理
境界を用意しないとキャラクターが背景のない位置まで際限なく移動してしまうので画面端に境界が必要です。

画面端に到着したらスクロールをやめてキャラクターの移動に切り替えます。画面端から離れたら再びスクロールを行います。

コードは以下の通り。
「キャラクターが画面の端にいるか」を判定して端にいる場合はスクロールを中断してキャラクターの移動に切り替えます。

```python:

def update(self):
	g = gravity()
	x = self.player.position.x
	y = self.player.position.y
	# 左右の移動
	if abs(g.x) > 0.05:
		max_speed = 40
		self.update_position(max_speed)
	else:
		self.player.texture = standing_texture
		self.walk_step = -1
		
def update_position(self, max_speed):
	g = gravity()
	x = self.player.position.x
	y = self.player.position.y
	distance = g.x * max_speed 
# 追加ここから --------------------------------------------------------------------------
	self.move_count = max(0, min(self.move_count + distance, self.stage_size))
	if self.move_count < self.size.w/2 or self.move_count >= self.stage_size - self.size.w/2:
		self.player.x_scale = cmp(g.x, 0)
		step = int(self.player.position.x / 40) % 2
		if step != self.walk_step:
			self.player.texture = walk_textures[step]
			self.walk_step = step
		# 左右の移動
		x = max(0, min(self.size.w, x + distance))
		self.player.position = (x, y)
	else:
# 追加ここまで --------------------------------------------------------------------------
		x = self.size.w/2
		self.player.position = (x, y)
		for t in self.tiles:
			t['tile1'].position = (t['tile1'].position.x - distance, t['tile1'].position.y)
			t['tile2'].position = (t['tile2'].position.x - distance, t['tile2'].position.y)
		for item in self.items:
			item.position = (item.position.x - distance, item.position.y)
		for item in self.stars:
			item.position = (item.position.x - distance, item.position.y)
		for item in self.mushrooms:
			item.position = (item.position.x - distance, item.position.y)
```

### 課題
1.画面の端でジャンプすると画面中央にワープすることがある。
2.スクロール中キャラクターは移動の扱いになっていないので歩行アニメーションが停止する

## コード全文
スクロールと関係ない処理の説明は割愛します。

```python:

# coding: utf-8

from scene import *
from sound import *

def cmp(a, b):
	return ((b > a) - (b < a))
	
	
standing_texture = Texture('emj:Ghost')
walk_textures = [Texture('emj:Ghost'), Texture('emj:Ghost')]

class Game (Scene):
	def setup(self):
		# バックグラウンド設定
		self.background_color = '#004f82'
		ground = Node(parent=self)
		x = 0
		# 地面の高さ
		self.base_height = 50
		self.stage_size = self.size.w * 5
		# 背景の設定
		self.tiles = []
		while x <= self.stage_size + 128:
			tile1 = SpriteNode('plf:Ground_GrassHalf_mid', position=(x - 50, self.base_height))
			ground.add_child(tile1)
			
			tile2 = SpriteNode('plf:Ground_GrassCenter', position=(x - 50, self.base_height-32))
			ground.add_child(tile2)
			x += 64
			self.tiles.append(
				{
					'tile1' : tile1,
					'tile2' : tile2
				}
			)
			
		# プレイヤーの初期設定
		self.player_height = self.base_height + 32
		self.player = SpriteNode('emj:Ghost')
		self.player.anchor_point = (0.5, 0)
		self.player.position = (self.size.w/2, self.player_height)
		self.add_child(self.player)
		# ジャンプボタンの初期設定
		self.jump = 'ready'
		self.jump_button = SpriteNode('emj:Blue_Circle', position=(320,50))
		self.add_child(self.jump_button)
		# 攻撃ボタンの初期設定
		self.attack_button = SpriteNode('emj:Blue_Circle', position=(250,50), color='red')
		self.add_child(self.attack_button)
		
		self.bullet = SpriteNode('shp:sun')
		self.bullet.position = -1000, -1000
		self.add_child(self.bullet)
		
		mushroom = SpriteNode('emj:Mushroom')
		mushroom.anchor_point = (0.5, 0)
		mushroom.position = (self.size.w * 3, self.player_height)
		self.add_child(mushroom)
		self.mushrooms = [mushroom]
		
		star = SpriteNode('plc:Star')
		star.anchor_point = (0.5, 0)
		star.position = (self.size.w + self.size.w/2, self.player_height + 300)
		self.add_child(star)
		self.stars = [star]
		
		self.items = []
		for i in range(0, 5):
			for j in range(0,2):
				item = SpriteNode('plf:HudCoin')
				item.anchor_point = (0.5, 0)
				item.position = (self.size.w + 80 + i * 50, self.player_height + 100 + 60 * j)
				self.add_child(item)
				self.items.append(item)
		self.charge = False
		self.power = 0
		self.disp_lock = False
		self.move_count = self.size.w/2
		
		score_font = ('Futura', 40)
		self.score_label = LabelNode('0', score_font, parent=self)
		# The label is centered horizontally near the top of the screen:
		self.score_label.position = (self.size.w/2, self.size.h - 70)
		# The score should appear on top of everything else, so we set the `z_position` attribute here. The default `z_position` is 0.0, so using 1.0 is enough to make it appear on top of the other objects.
		self.score_label.z_position = 1
		self.score = 0
		self.walk_step = -1
		
		
	def update(self):
		g = gravity()
		x = self.player.position.x
		y = self.player.position.y
		# 左右の移動
		if abs(g.x) > 0.05:
			max_speed = 40
			self.update_position(max_speed)
		else:
			self.player.texture = standing_texture
			self.walk_step = -1
		self.check_jump(x,y)
		if self.charge and self.power < 100:
			self.power += 1
			
		for item in self.mushrooms:
			item.position = (item.position.x -1, item.position.y)
		# 当たり判定
		self.check_item_hit(self.mushrooms, score=500)
		self.check_item_hit(self.items)
		self.check_item_hit(self.stars, score=1500)
		
		
	def update_position(self, max_speed):
		g = gravity()
		x = self.player.position.x
		y = self.player.position.y
		distance = g.x * max_speed 
		
		self.move_count = max(0, min(self.move_count + distance, self.stage_size))
		if self.move_count < self.size.w/2 or self.move_count >= self.stage_size - self.size.w/2:
			self.player.x_scale = cmp(g.x, 0)
			step = int(self.player.position.x / 40) % 2
			if step != self.walk_step:
				self.player.texture = walk_textures[step]
				self.walk_step = step
			# 左右の移動
			x = max(0, min(self.size.w, x + distance))
			self.player.position = (x, y)
		else:
			x = self.size.w/2
			self.player.position = (x, y)
			for t in self.tiles:
				t['tile1'].position = (t['tile1'].position.x - distance, t['tile1'].position.y)
				t['tile2'].position = (t['tile2'].position.x - distance, t['tile2'].position.y)
			for item in self.items:
				item.position = (item.position.x - distance, item.position.y)
			for item in self.stars:
				item.position = (item.position.x - distance, item.position.y)
			for item in self.mushrooms:
				item.position = (item.position.x - distance, item.position.y)
		
	def touch_began(self, touch):
		self.power = 0
		self.charge = True
		
	def touch_ended(self, touch):
		# タップした位置の取得
		touch_loc = self.point_from_scene(touch.location)
		# タップした位置がボタンならジャンプ
		if touch_loc in self.jump_button.frame:
			if self.jump == 'ready':
				play_effect('game:Boing_1')
				self.jump = 'up'
		if touch_loc in self.attack_button.frame:
			if self.jump == 'ready':
				x, y = self.player.position
				self.bullet.position = x, y + 50
				move = Action.move_to(x, self.player_height +1000, 0.5 ,TIMING_LINEAR)
				self.bullet.run_action(move)
				
	def check_jump(self, x, y):
		# 上昇時の処理
		if self.jump == 'up':
			max_height = 180 + self.power
			up_speed = 10
			y = max(self.player_height, min(self.size.h, y + up_speed))
			self.player.position = (x, y)
			if y > max_height + self.player_height:
				self.jump = 'down'
				
		# 落下時の処理
		if self.jump == 'down':
			down_speed = 10
			y = max(self.player_height, min(self.size.h, y - down_speed))
			self.player.position = (x, y)
			if y == self.player_height:
				self.jump = 'ready'
				self.charge = False
				
				
	# 当たり判定
	def check_item_hit(self, items, score=100):
		# 縦横一致したらスコア加算処理をしてアイテムを消す
		for item in items:
			if abs(self.player.position.x - item.position.x) < 30 and abs(self.player.position.y - item.position.y) < 30:
				item.remove_from_parent()
				self.score += score
				items.remove(item)
				self.score_label.text = str(self.score)
			if abs(self.bullet.position.x - item.position.x) < 30 and abs(self.bullet.position.y - item.position.y) < 30:
				item.remove_from_parent()
				self.score += score
				items.remove(item)
				self.score_label.text = str(self.score)
				
if __name__ == '__main__':
	run(Game(), PORTRAIT, show_fps=True)
```

## 実行例(gifアニメ)
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/415049/c1de2f03-7ba1-37a2-a589-a41edebad947.gif" width="200px">

Pythonistaの使い方はブログでもまとめ中。上記コードのスクロール以外のアクションについてはブログで解説しています。
[Pythonistaの使い方まとめ](https://se.miyabikno-jobs.com/summary-pythonista/)