#動画
#はじめに
最近りなちゃんボードを自作する人が増えていますが、マイコンを使ったり、3Dプリンターを使ったりするのは難しそう……高校生の璃奈ちゃんがあんな短期間で工作出来るわけないだろ、と思う方も多いでしょう。
私もそう思ったので、より現実的に、iPadやiPhoneだけで「りなちゃんボード」を再現(表示)するプログラムを作りました。
コンセプトは、
誰でも,簡単に,どんな時でも使える璃奈ちゃんボードです。
使用する言語はpython3.xのみです。
#必要なもの
- iOS、iPadOSアプリ「Pythonista3」(1200円)
- iPhoneやiPad(原寸大で作るためには9.7インチか、10.2インチ、11インチのiPadが必要)
たったこれだけ!
1200円のアプリは確かに高いですが、このアプリを入れるだけでほとんどのpythonプログラムが実行できるので、初心者の方も玄人の方もおすすめです。今回はPythonista に入っているuiモジュールを使うので必須となります。
#用件定義
#####りなちゃんボードとは
「ラブライブ!虹が咲学園スクールアイドル同好会」内に登場する高校1年生の女の子、天王寺璃奈は人前で感情を伝えることが大の苦手。そこで、様々な自分の表情を描いた「璃奈ちゃんボード」を常備し、それを顔前に掲げることで相手に思いを伝え易くなった。日常生活ではスケッチブックに描いたボードを使っているが、ライブの時には画面のついたヘッドセットを装着し、音楽に合わせて様々な顔を表示するボードを使う。
今回はこのボードの画面を完全に再現しようと思う。
#####ボードの仕様
ボードのマス目の数はアニメ、ゲーム漫画など、媒体により画面比が異なっているが、ここではアニメ版の画面比構成を採用する。
すなわち、実質的な描画領域は横32マス×縦18マスくらいの長方形で、領域の外にも白マスが途切れなく続いている。
- 画面上のドット絵で顔を表現(これは必須!)
- 時間経過に応じた遅延のないアニメーション
- 曲に合わせたり、セリフに合わせたりなど、様々な場面で使えるように、スクリプトを簡略化。作業時間を短縮する。
<いつか実装したい機能>
- BlueToothLEを用いた通信を行うことで、マイコンや3Dプリンターで作った本物の「璃奈ちゃんボード」と接続。データをリアルタイムで出力する。(Pythonista3はBluetooth通信ができるので理論上可能)
#実践(試行錯誤)
###顔の描画方法
とりあえず、まずはドット絵だけでも再現しようと思い、2日間の試行錯誤の末以下のようになった。
18×32個のボタンを並べ、押したところがピンク色に変化するだけ。
どこに色が付いているか分かるように左上のマスを基準にして座標を設定。
折角作ったこの顔を残せないのは勿体ない……
ピンク色になっているボタンの座標を1行に列挙してcsvに保存する。次回このcsvファイルを読み込めば、この顔が表示される。
そしてこれを何行も書いていけば、顔をスライドショーのように表示できるのだが……↓
- 見づらい!
- 1つの顔を表示するための文章量が大きい。
- データを見ただけで、どんな顔が表示されるのか分からない。
- 顔を部分的に修正することが困難(例えば、「目」のパーツだけを変えたいとき、顔全体を作り直す必要がある)。
など、問題点が多い。
これでは「使いやすい璃奈ちゃんボード」というコンセプトに合わない。
そこで思いついたのがプリセットの登録機能である。
よく使う「目」や「口」のパーツを別ファイルに登録しておいて、短い単語で簡単に呼び出せる(表示できる)ようにすれば良い。
mouth1,mouth2などの名前をプリセットに登録して…
実際に使う時には↓
このわずか4行で、顔を上から順に4種類表示するスクリプトが書けた。(表示時間は3秒ずつ)
プリセット一覧は別の記事で
###デザイン
ここで画面のデザインも本物そっくりに作り直し。
(アンカーポイントの設定が曖昧で、端のマスが切れているところも再現した)
もう少しいい顔はなかったのだろうか...
白の正方形の図形ノード(Pythonista3ではShapeNode)を端まで敷き詰めて、各ブロックの背景色をピンクと白に切り替えてドット絵を表現している。
ボード外枠の画像を付けるとこんな感じ↓
背景色を白→灰色にしてみた。
璃奈ちゃんボード、「にっこりん❤️」
#導入方法(まずは再生するところまで)
#####配布しているデータについて
課題曲はアニメ第6話挿入歌「ツナガルコネクト」。私はこのMVを見て璃奈ちゃんボードの制作を志しました。
最初の40秒だけ再現しています。
#####ダウンロード方法
ファイルに保存してから、zipを解凍。
connect.csvとRinachan.aiffを選択してチェックマークをタップ
#####タイミング(誤差)調整作業
- 起動後、画面中央をタップ(端をタップすると別機能が起動します)。最後までスクリプトを再生する。
<この時点では時間の誤差、上下左右のずれがあるので、これから補正します>
- 画面右上の×で実行を終了して、コンソールをチェックする。たくさんの数字の羅列が出力されています。
これは、スクリプトに書いてある時間と、実際に描画が完了した時間の誤差です。各フレームごとについて出力されています。正の値はスクリプトより遅い、負の値は早いことを示しています。
これが0に近づくように調整をします。
RinachanBoard.py
の始めの部分にはパラメータの変更をする場所があります。
今回は、写真の26行目timeduration
に誤差(秒)を入力します。負の値は表示を早め、正の値は表示を遅らせます。(わざわざ表示を遅らせる人は少ないと思うので、多くの人は負の値を記入すると思います。)
(参考までに、iPadPro 11インチでは-0.02で丁度良くなりました。)
#####位置調整作業
続けて、表示される顔の上下左右のズレを直します。変更するパラメータはoffsetX
とoffsetY
です。
34,35行目に移動するピクセル数を入力します。しかし、このままではどれくらいズラせば良いのかわかりません。
そんなときに役に立つのはDEBUGMODE
です。DEBUGMODE=True
とすることで起動画面に1ブロック(1ドット)のサイズを表示してくれます。(サイズは端末の大きさに応じて自動計算されます)
例えば、1マス41ピクセルで2.5マス分ずらしたいときは、41*2.5=102.5ピクセルずらせば良い、という感じです。
#操作方法(基礎編)
-
起動後、「チャリーン♪」となったら読み込み完了です。画面のどこか(デバッグモードの時は、画面中央)をタップすると再生が始まります。
-
再生中に画面を数回タップするともう一度最初から再生します。
-
〈デバッグモードのみ〉再生中に画面右端をタップすると1つ先のフレームに進みます。左端をタップすると1つ手前のフレームに戻ります。(まだバグが残っていて、表示が乱れることがあります)
#設定できる項目一覧
RinachanBoard.py
の始めの部分にはいくつか調整できるパラメータがあります。必要に応じて弄ってください。
'''
###########↓設定/config##########
'''
scriptFile='connect.csv'
musicFile='Rinachan.aiff'
timeduration=-0.02#時間ズレ補正,単位は秒
sizemode=0 #0:縦幅を基準 1:横幅を基準に合わせます。横画面時は0が規定値
DEBUGMODE=True #True,False
boadcolor=[('#cccccc'),('#d00fd9')]#ボードの色[白,ピンク]
linecolor='#eaeaea' #枠線の色. line's color
offsetX=42#x軸ズレ補正
offsetY=-10#y軸ズレ補正
blockSize=0#ブロックのサイズ。0の時、自動設定
'''
##########↑設定ここまで##########
'''
- scriptFile:使用するスクリプトのファイル名を入れる。拡張子(.csv)も忘れずに。
- musicFile:音源のファイル名を入れる
- timeduration:時間補正(前述)
- sizemode:横画面の時は0が良い
- DEBUGMODE:デバッグ画面を出したい時はTrue、消す時はFalse
- boadcolor:ボードの色を設定。list形式になっていて、形は[白,ピンク]
- linecolor:ブロックの輪郭線の色を設定
- offsetX:位置補正(前述)大きくすると右に動く
- offsetY:位置補正(前述)大きくすると上に動く
- blockSize:本来、端末の画面サイズに応じて一つ一つのブロックのサイズが自動設定されるが、手動で設定したい時はこの値をいじる。
#コード
一応載せておきますが、長いので折りたたみ。上のリンクからダウンロードした方が早い。
from scene import *
import sound
import random
import math
import ui
import csv
import re
import time
import ast
'''
#####################
璃奈ちゃんボードビューアー ver2.8
Rina-chan Board Viewer ver2.8
#####################
Created by @NSmagnets
If you find any bugs,please inform me on Twitter(@NSmagnets)
使い方/How To Use
1、準備中……
###########↓設定/config##########
'''
scriptFile='connect.csv'#connect.csv
musicFile='Rinachan.aiff'
timeduration=-0.02#時間ズレ補正,単位は秒
sizemode=0 #0:縦幅を基準 1:横幅を基準に合わせます。横画面時は0が規定値
DEBUGMODE=False #True,False
boadcolor=[('#cccccc'),('#d00fd9')]#ボードの色[白,ピンク] Board's Color[(white),(pink)]
#boadcolor=[('#ffffff'),('#b700bf')]
linecolor='#eaeaea' #枠線の色. line's color
#boadcolor=[('#c9c9c9'),'#f400ff']
offsetX=42#x軸ズレ補正
offsetY=-10#y軸ズレ補正
blockSize=0#ブロックのサイズ。0の時、自動設定
'''
##########↑設定ここまで##########
'''
#
'''
#「script」「music」というファイルを作り、そこにスクリプト(csv)と曲(wavなど)を入れれば、
#ファイルが整理されます。その際には' ' 'を削除してください
scriptFile='./script/'+scriptFile
musicFile='./music/'+musicFile
'''
global scene
color=[]#[9_9,2_3,3_4]
boforecolor=[]
global boad
class boad (SpriteNode):
def setup(self):
bby = SpriteNode('ii.jpg', position=(self.size.w/2, self.size.h/2))
#SpriteNode(self, 'shp:RoundRect', **kwargs)
self.add_child(bby)
class MyScene (Scene):
def csv_import(self):
tapp=[]
ee=[]
self.boadU=[]
#bill=[]
self.preset={}
self.dataprev=[]
with open('preset.csv') as f:
reader = csv.reader(f)
l=[row for row in reader]
#print(l)
for yo in range(len(l)):
#print(l[yo])
#preset.update(dict(l[yo]))
p=str(l[yo]).replace('["', '')
q=p.replace('"]','')
#m=q.replace('"','')
s1=q.replace("/'",'')
ss=ast.literal_eval(s1)
self.preset.update(ss)
with open(scriptFile) as f:
reader = csv.reader(f)
l=[row for row in reader]
#self.deta=l
#print(l)
for yo in range(len(l)):
if '<' in l[yo][0]:
pass
else:
self.dataprev.append([l[yo]])
tapp=[]
ee=[]
#a=int(l[yo][0])
a=int(yo)
k=l[yo][1]
b=l[yo][2]
c=float(l[yo][3])
#print(type(k))
#reader2= csv.reader(l)
#for row in reader2:
if '_' in k:
p=k.replace('(', '')
q=p.replace(')','')
m=q.replace('"','')
s=re.split(', ',m)
else:
p=re.split('//',k)
#print(p)
for e in p:
qq=[self.preset[e][e1] for e1 in range(len(self.preset[e]))]
ee.extend(qq)
#print(ee)
s=[uu for uu in ee]#配列外しの0。変更禁止
#print(s,'s')
for i in s:
if type(i)==tuple:
s1=str(i).replace("('",'')
s2=s1.replace("')",'')
s5=re.split('_',str(s2))
s6=[re.sub('\\D','',ty) for ty in s5]
#print(s6)
else:
s2=i.replace("/'",'')
# s11=s1.replace("")
s4=re.split('_',str(s2))
#s5=ast.literal_eval(s4)
s3=(int(s4[0])-1,19-int(s4[1]))#***+1,,19-***
tapp.append(s3)
#print(tapp)
self.boadU.append([a,tapp,b,c])
print(l[yo])
#print(self.dataprev)
#print(boadU[0][2])
def setup(self):
global offsetX,offsetY
print(self.size.h)
if blockSize==0:
c=41#36.7
c=(self.size.h//21)+2
if sizemode==1:
c=(self.size.w//30)
elif sizemode==2:
c=35
offsetX=120
offsetY=0
timeduration=0.03
else:
c=blockSize
self.alltime=0.0
self.epoctime=0.0
self.duration=0.0
self.ready= False
self.boad=[]
self.block={}
self.playing=False
self.background_color = '#d9d9d9'
#self.background_color = 'black'
colorNo=0
self.scenes=0
self.touchcount=0
#self.music=musicFile
self.debugmode=DEBUGMODE
self.onlights=[]
self.offlights=[]
self.dataprev=[]
path = ui.Path()
path.line_width = 3
path.move_to(0, 0)
#path.line_to(200, 0)
path.line_to(0, self.size.h)
path.close()
#②
path2=ui.Path()
path2.line_width = 1
path2.move_to(0, 0)
#path.line_to(200, 0)
path2.line_to(0, c)
path2.line_to(c, c)
path2.line_to(c, 0)
path2.close()
#path = ui.Path.rect(0, 0, c, c)
for x in range(34):
for y in range(28):
self.rectangle3=ShapeNode(path2,fill_color=boadcolor[0],stroke_color=linecolor,position=(c*(x-1)+offsetX,c*y+offsetY))
#self.rectangle = SpriteNode(boadtex[0], position=(c*x+10, c*y))
z=(x,y)
self.boad.append([z,colorNo])
self.block[z]=self.rectangle3
#④
self.add_child(self.rectangle3)
self.rectangle3.fill_color=boadcolor[1]
z=(7,9)
'''
'''
self.boadcopy=self.boad
if self.debugmode==True:
score_font = ('Futura', c*1)
self.score_label = LabelNode('0', score_font, parent=self)
self.score_label.position = (self.size.w / 2, self.size.h - 120)
self.score_label.z_position = 1
self.score_label.color='#0016ff'
self.add_child(self.score_label)
self.score_label.text='DEBUG MODE ver2.8 block='+str(c)
self.triangle2 = ShapeNode(path,fill_color='#66ffc3', stroke_color='#18a56e',position=(self.size.w/2, self.size.h/2), parent=self)
self.add_child(self.triangle2)
#self.block[z].texture=boadtex[1]
sound.play_effect('arcade:Coin_2')
self.csv_import()
#sound.play_effect('arcade:Coin_1')
for i in range( len(self.boadU)):
self.preload(i)
#print(self.onlights)
del offsetX,offsetY
if sizemode==2:
bby = SpriteNode('boad.png', position=(self.size.w/2, self.size.h/2),size=(self.size.w,self.size.h))
#SpriteNode(self, 'shp:RoundRect', **kwargs)
self.add_child(bby)
sound.play_effect('game:Ding_3')
def preload(self,scene):
global color
#scene=scenepic.text
on=[]
off=[]
boforecolor=color
color=[]
#for r in len(boadU):
#if scene== len(boadU):
#print('heheheowowowoowo')
v=self.boadU[int(scene)][1]
duration=self.boadU[int(scene)][3]
#t = threading.Timer(float(duration)-0.05,lambda:autoupdate(sender , int(scene)+1) )
#clear(sender)
#for qq in range():
for www in self.boadcopy:
we=www[0]
#buttonbg= sender.superview[we]
if [we,0] in self.boadcopy:
ww=self.boadcopy.index([we,0])
else:
ww=self.boadcopy.index([we,1])
#tt=colored[ww]
self.boadcopy[ww:ww+1]=[[we,0]]
#print(self.boad)
#buttonbg.background_color= ('#ffffff')
#vv=list(v)
'''colored
'''
#u=sender.name
#if [uu,False] in colored:
#ww=colored.index([uu,False])
#else:'''
for x in v:
y=[]
#y=x.replace('"','')
#print(colored)
if [x,0] in self.boadcopy:
ww=self.boadcopy.index([x,0])
else:
ww=self.boadcopy.index([x,1])
tt=self.boadcopy[ww]
#buttonbg= sender.superview[y]
#self.boad[ww:ww+1]=[[x,1]]
#buttonbg.background_color= ('#fb99ff')
color.append(self.boadcopy[ww][0])
#tup
if boforecolor==None:
pass
else:
for bef in boforecolor:
#buttonbg=sender.superview[bef]
if bef in color:
pass
else:
#self.block[bef].fill_color=boadcolor[0]
off.append(bef)
#buttonbg.background_color= ('#ffffff')
for co in color:
#buttonbg=sender.superview[co]
if co in boforecolor:
pass
else:
#self.block[co].fill_color=boadcolor[1]
on.append(co)
#buttonbg.background_color= ('#fb99ff')
self.onlights.append(on)
self.offlights.append(off)
#ssd=(time.time()-self.epoctime-self.alltime+self.duration)
#print('. .',ssd,self.scenes,str(self.checkcolor)+'Blocks')
def autoupdate(self,scenes):
global color
#scene=scenepic.text
boforecolor=color
color=[]
self.checkcolor= len(color+boforecolor)
'''colV= sender.superview['colorView']'''
#for r in len(boadU):
#if scene== len(boadU):
#print('heheheowowowoowo')
v=self.boadU[int(self.scenes)-1][1]
duration=self.boadU[int(self.scenes)-1][3]
#t = threading.Timer(float(duration)-0.05,lambda:autoupdate(sender , int(scene)+1) )
#clear(sender)
#for qq in range():
for www in self.boad:
we=www[0]
#buttonbg= sender.superview[we]
if [we,0] in self.boad:
ww=self.boad.index([we,0])
else:
ww=self.boad.index([we,1])
#tt=colored[ww]
self.boad[ww:ww+1]=[[we,0]]
#print(self.boad)
#buttonbg.background_color= ('#ffffff')
#vv=list(v)
'''colored
'''
#u=sender.name
#if [uu,False] in colored:
#ww=colored.index([uu,False])
#else:'''
for x in v:
y=[]
#y=x.replace('"','')
#print(colored)
if [x,0] in self.boad:
ww=self.boad.index([x,0])
else:
ww=self.boad.index([x,1])
tt=self.boad[ww]
#buttonbg= sender.superview[y]
self.boad[ww:ww+1]=[[x,1]]
#buttonbg.background_color= ('#fb99ff')
color.append(self.boad[ww][0])
#tup.append([ww])
if boforecolor==None:
pass
else:
for bef in boforecolor:
#buttonbg=sender.superview[bef]
if bef in color:
pass
else:
self.block[bef].fill_color=boadcolor[0]
#buttonbg.background_color= ('#ffffff')
for co in color:
#buttonbg=sender.superview[co]
if co in boforecolor:
pass
else:
self.block[co].fill_color=boadcolor[1]
#buttonbg.background_color= ('#fb99ff')
ssd=(time.time()-self.epoctime-self.alltime+self.duration)
print('. .',ssd,self.scenes,str(self.checkcolor)+'Blocks')
if self.debugmode==False:
pass
else:
sse=int(ssd*1000)
#wwq=time.time()-self.epoctime-self.alltime+self.duration,self.scenes,str(self.checkcolor)
self.score_label.text=str(self.dataprev[self.scenes-1])+str(self.scenes)+' '+str(sse)
#colV.text = str(v)
#t.start()
#AutoUpdateNEO
def autoupdate2(self,scenes):
global color
if self.offlights==None:
pass
else:
for bef in self.offlights[self.scenes]:
self.block[bef].fill_color=boadcolor[0]
for co in self.onlights[self.scenes]:
self.block[co].fill_color=boadcolor[1]
self.scenes+=1
ssd=(time.time()-self.epoctime-self.alltime+self.duration)
print('. .',ssd,self.scenes)
if self.debugmode==False:
pass
else:
sse=int(ssd*1000)
self.score_label.text='Auto'+str(self.dataprev[self.scenes-1])+str(self.scenes)+' '+str(sse)
def timekeep(self,scenes):
if self.scenes==len(self.boadU):
self.ready=False
self.playing=False
pass
else:
self.duration=self.boadU[int(self.scenes)][3]
self.alltime =self.alltime + self.duration
self.autoupdate2(self.scenes)
def colorchange(self):
#print(self.boad)
pass
def did_change_size(self):
sound.stop_all_effects()
self.ready=False
self.playing=False
#self.remove_all_actions()
#self.setup()
pass
def update(self):
#self.T=time.time()
#print(self.T)
if self.ready == True:
if self.alltime+timeduration<= time.time()-self.epoctime:
self.timekeep(self.scenes)
def touch_began(self, touch):
if self.playing==False:
global offsetX,offsetY,blockSize,linecolor,sizemode,scriptFile
try:
del offsetX,offsetY,blockSize,linecolor,sizemode,scriptFile
except :
pass
self.scenes=0
self.alltime=0.0
for qwe in self.block.values():
qwe.fill_color=boadcolor[0]
sound.stop_all_effects()
sound.play_effect('arcade:Coin_4')
time.sleep(1)
ee=time.time()
sound.play_effect(musicFile)
print(time.time()-ee)
t=touch.location
print(t)
self.playing=True
self.ready=True
self.epoctime=time.time()
self.timekeep(0)
else:
self.touchcount+=1
if self.touchcount==3:
self.playing=False
self.touchcount=0
pass
t=touch.location.x
if self.debugmode==False :
pass
else:
if self.scenes==len(self.boadU):
self.ready=False
self.playing=False
pass
else:
if self.size.w*0.1>=t:
print('prev')
self.ready=False
#self.playing=False
self.touchcount=0
self.scenes= self.scenes-1
#for qwe in self.block.values():
#qwe.fill_color=boadcolor[0]
self.autoupdate(self.scenes-1)
else:
if self.size.w*0.8<=t:
print('next')
self.ready=False
#self.playing=True
self.touchcount=0
self.autoupdate2(self.scenes)
print(t)
def stop(self):
sound.stop_all_effects()
def touch_moved(self, touch):
pass
def touch_ended(self, touch):
pass
if __name__ == '__main__':
run(MyScene(),'portrait', show_fps=True)