伊地知ニジカ放送局だぬ゛ん゛. https://www.youtube.com/@deerjika
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

499 lines
18 KiB

  1. # vim: nosmartindent autoindent
  2. import json
  3. import math
  4. import random
  5. import subprocess
  6. import sys
  7. import time
  8. from datetime import datetime, timedelta
  9. import emoji
  10. import ephem # type: ignore
  11. import pygame
  12. import pygame.gfxdraw
  13. import pytchat # type: ignore
  14. from playsound import playsound
  15. from pygame.locals import *
  16. from youtube import *
  17. import play_movie
  18. from aques import Aques
  19. from common_const import *
  20. from common_module import CommonModule
  21. from mode import Mode
  22. from talk import Talk
  23. class Main:
  24. kita_x: float = CWindow.WIDTH / 2
  25. kita_y: float = 1000000.
  26. kita_arg: float = 0.
  27. jojoko_x: float = CWindow.WIDTH / 2
  28. jojoko_y: float = 1000000.
  29. jojoko_arg: float = 0.
  30. @classmethod
  31. def main (
  32. cls,
  33. argv: list,
  34. argc: int) \
  35. -> None:
  36. mode: Mode = Mode.NIZIKA
  37. match (argc > 1) and argv[1]:
  38. case '-g':
  39. mode = Mode.GOATOH
  40. case '-w':
  41. mode = Mode.DOUBLE
  42. nizika_mode: bool = mode == Mode.NIZIKA
  43. goatoh_mode: bool = mode == Mode.GOATOH
  44. double_mode: bool = mode == Mode.DOUBLE
  45. print (mode)
  46. # ウィンドゥの初期化
  47. pygame.init ()
  48. screen: pygame.Surface = pygame.display.set_mode (
  49. (CWindow.WIDTH, CWindow.HEIGHT))
  50. # 大月ヨヨコの観測値
  51. observer = ephem.Observer ()
  52. observer.lat, observer.lon = '35', '139'
  53. # き太く陽オブジェクト
  54. sun = ephem.Sun ()
  55. # 大月ヨヨコ・オブジェクト
  56. moon = ephem.Moon ()
  57. # 吹き出し
  58. balloon = pygame.transform.scale (pygame.image.load ('talking.png'),
  59. (CWindow.WIDTH, 384))
  60. if goatoh_mode:
  61. balloon = pygame.transform.flip (balloon, False, True)
  62. # 背景(昼)
  63. bg_day: pygame.Surface = pygame.transform.scale (
  64. pygame.image.load ('bg.jpg'),
  65. (CWindow.WIDTH, CWindow.HEIGHT))
  66. # 背景(夕方)
  67. bg_evening: pygame.Surface = pygame.transform.scale (
  68. pygame.image.load ('bg-evening.jpg'),
  69. (CWindow.WIDTH, CWindow.HEIGHT))
  70. # 背景(夜)
  71. bg_night: pygame.Surface = pygame.transform.scale (
  72. pygame.image.load ('bg-night.jpg'),
  73. (CWindow.WIDTH, CWindow.HEIGHT))
  74. # 背景の草
  75. bg_grass: pygame.Surface = pygame.transform.scale (
  76. pygame.image.load ('bg-grass.png'),
  77. (CWindow.WIDTH, CWindow.HEIGHT))
  78. # き太く陽
  79. kita: pygame.Surface = pygame.transform.scale (
  80. pygame.image.load ('sun.png'), (200, 200))
  81. # 大月ヨヨコ
  82. jojoko: pygame.Surface = pygame.transform.scale (
  83. pygame.image.load ('moon.png'), (200, 200))
  84. # 音声再生器の初期化
  85. pygame.mixer.init (frequency = 44100)
  86. # ニジカの “ぬ゛ぅ゛ぅ゛ぅ゛ん゛”
  87. noon = pygame.mixer.Sound ('noon.wav')
  88. # “あっ!”
  89. deerjika_oh = pygame.mixer.Sound ('oh.wav')
  90. # おやつタイムのテーマ
  91. snack_time_sound = pygame.mixer.Sound ('snack_time.wav')
  92. # ゴートうの “ムムムム”
  93. mumumumu = pygame.mixer.Sound ('mumumumu.wav')
  94. # ゴートうの “クサタベテル!!”
  95. kusa = pygame.mixer.Sound ('kusa.wav')
  96. # YouTube Chat オブジェクト
  97. live_chat = pytchat.create (video_id = YOUTUBE_ID)
  98. # デバッグ・メシジのフォント
  99. system_font = pygame.font.SysFont ('notosanscjkjp', 24, bold = True)
  100. # 視聴者コメントのフォント
  101. user_font = pygame.font.SysFont ('notosanscjkjp', 32, italic = True)
  102. # ニジカのフォント
  103. nizika_font = pygame.font.SysFont ('07nikumarufont', 50)
  104. # Youtube Chat から取得したコメントたち
  105. chat_items: list = []
  106. # 会話の履歴
  107. histories: list = []
  108. # おやつ記録
  109. has_snack = False
  110. while True:
  111. # 観測地の日づけ更新
  112. observer.date = datetime.now ().date ()
  113. # 日の出開始
  114. sunrise_start: datetime = (
  115. (ephem.localtime (observer.previous_rising (sun))
  116. - timedelta (minutes = 30)))
  117. # 日の出終了
  118. sunrise_end: datetime = sunrise_start + timedelta (hours = 1)
  119. # 日の入開始
  120. sunset_start: datetime = (
  121. (ephem.localtime (observer.next_setting (sun))
  122. - timedelta (minutes = 30)))
  123. # 日の入終了
  124. sunset_end: datetime = sunset_start + timedelta (hours = 1)
  125. # 時刻つき観測地
  126. observer_with_time: ephem.Observer = observer
  127. observer_with_time.date = datetime.now () - timedelta (hours = 9)
  128. # 日の角度
  129. sun.compute (observer_with_time)
  130. sun_alt: float = math.degrees (sun.alt)
  131. sun_az: float = math.degrees (sun.az)
  132. # 月の角度
  133. moon.compute (observer_with_time)
  134. moon_alt: float = math.degrees (moon.alt)
  135. moon_az: float = math.degrees (moon.az)
  136. # 月齢
  137. new_moon_dt: datetime = ephem.localtime (
  138. ephem.previous_new_moon (observer_with_time.date))
  139. moon_days_old: float = (
  140. (datetime.now () - new_moon_dt).total_seconds ()
  141. / 60 / 60 / 24)
  142. # 背景描画
  143. cls.draw_bg (screen, bg_day, bg_evening, bg_night, bg_grass,
  144. kita, jojoko,
  145. sunrise_start, sunrise_end, sunset_start, sunset_end,
  146. sun_alt, sun_az, moon_alt, moon_az, moon_days_old)
  147. # 左上に時刻表示
  148. for i in range (4):
  149. screen.blit (
  150. system_font.render (str (datetime.now ()), True, (0, 0, 0)),
  151. (i % 2, i // 2 * 2))
  152. if live_chat.is_alive ():
  153. # Chat オブジェクトが有効
  154. # Chat 取得
  155. chat_items = live_chat.get ().items
  156. if chat_items:
  157. # 溜まってゐる Chat からランダムに 1 つ抽出
  158. chat_item = random.choice (chat_items)
  159. # 投稿者情報を辞書化
  160. chat_item.author = chat_item.author.__dict__
  161. # 絵文字を復元
  162. chat_item.message = emoji.emojize (chat_item.message)
  163. message: str = chat_item.message
  164. if nizika_mode:
  165. goatoh_talking = False
  166. if goatoh_mode:
  167. goatoh_talking = True
  168. if double_mode:
  169. goatoh_talking = random.random () < .5
  170. while True:
  171. # ChatGPT API を呼出し,返答を取得
  172. answer: str = Talk.main (message, chat_item.author['name'], histories, goatoh_talking).replace ('\n', ' ')
  173. # 履歴に追加
  174. histories = (histories
  175. + [{'role': 'user', 'content': message},
  176. {'role': 'assistant', 'content': answer}])[(-12):]
  177. # ログ書込み
  178. with open ('log.txt', 'a') as f:
  179. f.write (f'{datetime.now ()}\t'
  180. + f'{json.dumps (chat_item.__dict__)}\t'
  181. + f'{answer}\n')
  182. cls.draw_talking (screen, balloon, user_font, nizika_font,
  183. message, answer, mode,
  184. mode == 3 and goatoh_talking)
  185. # 鳴く.
  186. if goatoh_talking:
  187. if random.random () < .1:
  188. kusa.play ()
  189. else:
  190. mumumumu.play ()
  191. else:
  192. noon.play ()
  193. time.sleep (1.5)
  194. cls.read_out (answer, goatoh_talking)
  195. if not double_mode or random.random () < .5:
  196. break
  197. cls.draw_bg (screen, bg_day, bg_evening, bg_night,
  198. bg_grass, kita, jojoko,
  199. sunrise_start, sunrise_end,
  200. sunset_start, sunset_end,
  201. sun_alt, sun_az, moon_alt, moon_az,
  202. moon_days_old)
  203. chat_item.author = {'name': 'ゴートうひとり' if goatoh_talking else '伊地知ニジカ',
  204. 'id': '',
  205. 'imageUrl': './favicon-goatoh.ico' if goatoh_talking else './favicon.ico'}
  206. chat_item.message = histories.pop (-1)['content']
  207. message = chat_item.message
  208. goatoh_talking = not goatoh_talking
  209. else:
  210. # Chat オブジェクトが無効
  211. # 再生成
  212. live_chat = pytchat.create (video_id = YOUTUBE_ID)
  213. if has_snack and datetime.now ().hour == 14:
  214. has_snack = False
  215. pygame.display.update ()
  216. if (not has_snack) and datetime.now ().hour == 15:
  217. has_snack = True
  218. deerjika_oh.play ()
  219. time.sleep (0.6)
  220. snack_time_sound.play ()
  221. play_movie.main (screen, 'snack_time.mp4')
  222. query = 'おやつタイムだ!!!!'
  223. cls.draw_bg (screen, bg_day, bg_evening, bg_night, bg_grass,
  224. kita, jojoko,
  225. sunrise_start, sunrise_end, sunset_start, sunset_end,
  226. sun_alt, sun_az, moon_alt, moon_az, moon_days_old)
  227. answer = Talk.main (query).replace ('\n', ' ')
  228. cls.draw_talking (screen, balloon, user_font, nizika_font,
  229. query, answer)
  230. noon.play ()
  231. time.sleep (1.5)
  232. cls.read_out (answer)
  233. for event in pygame.event.get ():
  234. if event.type == QUIT:
  235. pygame.quit ()
  236. sys.exit ()
  237. @staticmethod
  238. def read_out (
  239. answer: str,
  240. goatoh: bool = False,
  241. ) -> None:
  242. # 返答の読上げを WAV ディタとして生成,取得
  243. wav: bytearray | None
  244. try:
  245. wav = Aques.main (answer, goatoh)
  246. except:
  247. wav = None
  248. # 読上げを再生
  249. if wav is not None:
  250. with open ('./nizika_talking.wav', 'wb') as f:
  251. f.write (wav)
  252. playsound ('./nizika_talking.wav')
  253. time.sleep (1)
  254. @staticmethod
  255. def draw_talking (
  256. screen: pygame.Surface,
  257. balloon: pygame.Surface,
  258. user_font: pygame.font.Font,
  259. nizika_font: pygame.font.Font,
  260. query: str,
  261. answer: str,
  262. mode: Mode = Mode.NIZIKA,
  263. flip: bool = False,
  264. ) -> None:
  265. # 吹出し描画(ニジカは上,ゴートうは下)
  266. nizika_mode = False
  267. goatoh_mode = False
  268. double_mode = False
  269. match mode:
  270. case Mode.NIZIKA:
  271. screen.blit (balloon, (0, 0))
  272. nizika_mode = True
  273. case Mode.GOATOH:
  274. screen.blit (balloon, (0, 384))
  275. goatoh_mode = True
  276. case Mode.DOUBLE:
  277. screen.blit (pygame.transform.flip (balloon, flip, False), (0, 0))
  278. double_mode = True
  279. # 視聴者コメント描画
  280. screen.blit (
  281. user_font.render (
  282. ('> ' + (query
  283. if (CommonModule.len_by_full (query) <= 21)
  284. else (CommonModule.mid_by_full (query, 0, 19.5) + '...'))),
  285. True, (0, 0, 0)),
  286. ((120, 70 + 384)
  287. if goatoh_mode
  288. else (120 + (64 if (double_mode and flip) else 0), 70)))
  289. # ニジカの返答描画
  290. screen.blit (
  291. nizika_font.render (
  292. (answer
  293. if CommonModule.len_by_full (answer) <= 16
  294. else CommonModule.mid_by_full (answer, 0, 16)),
  295. True,
  296. (192, 0, 0)),
  297. (100, 150 + 384) if goatoh_mode else (100 + (64 if (double_mode and flip) else 0), 150))
  298. if CommonModule.len_by_full (answer) > 16:
  299. screen.blit (
  300. nizika_font.render (
  301. (CommonModule.mid_by_full (answer, 16, 16)
  302. if CommonModule.len_by_full (answer) <= 32
  303. else (CommonModule.mid_by_full (
  304. answer, 16, 14.5)
  305. + '...')),
  306. True,
  307. (192, 0, 0)),
  308. (100, 200 + 384) if goatoh_mode else (100 + (64 if (double_mode and flip) else 0), 200))
  309. pygame.display.update ()
  310. @classmethod
  311. def draw_bg (
  312. cls,
  313. screen: pygame.Surface,
  314. bg_day: pygame.Surface,
  315. bg_evening: pygame.Surface,
  316. bg_night: pygame.Surface,
  317. bg_grass: pygame.Surface,
  318. kita_original: pygame.Surface,
  319. jojoko_original: pygame.Surface,
  320. sunrise_start: datetime,
  321. sunrise_end: datetime,
  322. sunset_start: datetime,
  323. sunset_end: datetime,
  324. sun_alt: float,
  325. sun_az: float,
  326. moon_alt: float,
  327. moon_az: float,
  328. moon_days_old: float,
  329. ) -> None:
  330. sunrise_centre: datetime = (
  331. sunrise_start + (sunrise_end - sunrise_start) / 2)
  332. sunset_centre: datetime = (
  333. sunset_start + (sunset_end - sunset_start) / 2)
  334. jojoko: pygame.Surface = cls.get_jojoko (jojoko_original,
  335. moon_days_old, moon_alt, moon_az)
  336. x = CWindow.WIDTH * (sun_az - 80) / 120
  337. y = ((CWindow.HEIGHT / 2)
  338. - (CWindow.HEIGHT / 2 + 100) * math.sin (math.radians (sun_alt)) / math.sin ( math.radians (60)))
  339. arg = math.degrees (math.atan2 (y - cls.kita_y, x - cls.kita_x))
  340. cls.kita_x = x
  341. cls.kita_y = y
  342. if abs (arg - cls.kita_arg) > 3:
  343. cls.kita_arg = arg
  344. kita: pygame.Surface = pygame.transform.rotate (kita_original, -(90 + cls.kita_arg))
  345. dt: datetime = datetime.now ()
  346. if sunrise_centre <= dt < sunset_centre:
  347. screen.blit (bg_day, (0, 0))
  348. else:
  349. screen.blit (bg_night, (0, 0))
  350. if sunrise_start <= dt < sunrise_end:
  351. bg_evening.set_alpha (255 - int ((abs (dt - sunrise_centre) * 510)
  352. / (sunrise_end - sunrise_centre)))
  353. elif sunset_start <= dt < sunset_end:
  354. bg_evening.set_alpha (255 - int ((abs (dt - sunset_centre) * 510)
  355. / (sunset_end - sunset_centre)))
  356. else:
  357. bg_evening.set_alpha (0)
  358. if sunrise_start <= dt < sunset_end:
  359. jojoko.set_alpha (255 - int (255 / 15 * abs (moon_days_old - 15)))
  360. else:
  361. jojoko.set_alpha (255)
  362. screen.blit (bg_evening, (0, 0))
  363. if (moon_az < 220) and (-10 <= moon_alt):
  364. screen.blit (jojoko, jojoko.get_rect (center = (cls.jojoko_x, cls.jojoko_y)))
  365. screen.blit (bg_grass, (0, 0))
  366. if (sun_az < 220) and (-10 <= sun_alt):
  367. screen.blit (kita, kita.get_rect (center = (cls.kita_x, cls.kita_y)))
  368. screen.blit (bg_grass, (0, 0))
  369. @classmethod
  370. def get_jojoko (
  371. cls,
  372. jojoko_original: pygame.Surface,
  373. moon_days_old: float,
  374. moon_alt: float,
  375. moon_az: float) \
  376. -> pygame.Surface:
  377. jojoko: pygame.Surface = jojoko_original.copy ()
  378. jojoko.set_colorkey ((0, 255, 0))
  379. for i in range (200):
  380. if 1 <= moon_days_old < 15:
  381. pygame.gfxdraw.bezier (jojoko, ((0, 100 + i), (100, 180 * moon_days_old / 7 - 80 + i), (200, 100 + i)), 3, (0, 255, 0))
  382. elif moon_days_old < 16:
  383. pass
  384. elif moon_days_old < 30:
  385. pygame.gfxdraw.bezier (jojoko, ((0, 100 - i), (100, 180 * (moon_days_old - 15) / 7 - 80 - i), (200, 100 - i)), 3, (0, 255, 0))
  386. else:
  387. jojoko.fill ((0, 255, 0))
  388. x = CWindow.WIDTH * (moon_az - 80) / 120
  389. y = ((CWindow.HEIGHT / 2)
  390. - (CWindow.HEIGHT / 2 + 100) * math.sin (math.radians (moon_alt)) / math.sin (math.radians (60)))
  391. arg = math.degrees (math.atan2 (y - cls.jojoko_y, x - cls.jojoko_x))
  392. cls.jojoko_x = x
  393. cls.jojoko_y = y
  394. if abs (arg - cls.jojoko_arg) > 3:
  395. cls.jojoko_arg = arg
  396. return pygame.transform.rotate (jojoko, -(90 + cls.jojoko_arg))
  397. if __name__ == '__main__':
  398. Main.main (sys.argv, len (sys.argv))