PyCon KR 2019 뱅크샐러드 돌아보기

뱅크샐러드가 작년에 다이아몬드 후원사로 참여한 데 이어 올해 [파이콘 한국 2019]에도 키스톤 후원사로 참여했습니다. 이번 글을 통해 파이콘에 참여한 뱅크샐러드의 이모저모를 돌아보려 합니다.

panorama
크고 아름다운 뱅크샐러드 부스

뱅크샐러드 파이콘 한국 2019 행사 스케치 영상

뱅크샐러드 파이콘 한국 2019 행사 스케치 영상. 위 썸네일을 눌러보세요!

Talks

뱅크샐러드의 엔지니어 네 분이 이번 파이콘에 발표자로 참여했습니다.

Our Goods

sticker1
부스를 방문하시는 분들께 드렸던 스티커
sticker2
Pythonic Challenge와 Adventure를 클리어하신 분께 드리는 업적 스티커

이외에도 후술할 Python Adventure/Challenge 게임의 진행도에 따라 렌즈 클리너, 티셔츠 그리고 상위권 랭커 분들에게는 아이패드 프로, 에어팟 2, 라즈베리 파이 4를 지급했습니다.

The Pythonic Adventure and Challenge

뱅크샐러드는 파이썬 축제에 재미를 더하고자 (그리고 아주 큰 부스를 채우고자) 작년 파이콘 한국 2018에서 선보인 온라인 이벤트, 뱅크샐러드 홀덤과 달리 이번에는 오프라인 이벤트 [The Pythonic Adventure and Challenge]를 준비했습니다.

booth
뱅크샐러드 부스와 비밀의 공간

참여 방식은 매우 간단한데, 뱅크샐러드 부스에 설치된 비밀의 공간(사진의 화살표 참고)에 한 명씩 입장해 저희가 준비한 게임을 약 90초간 즐기면 됩니다!

game
비밀의 공간에서는 이런 게임을 즐기실 수 있습니다

Pythonic Adventure와 Challenge는 같은 게임이지만 그래픽을 조금 다르게 구성했고, 출제 문제의 난이도 역시 달리해 구성했습니다. Adventure는 Python에 아직 익숙하지 않으신 분들도 쉽게 게임을 즐기실 수 있도록 준비했고, Challenge는 비교적 Python을 능숙하게 다루시는 분들이 즐겁게 즐기실 수 있는 난이도로 구성했습니다. 대략적인 게임 진행은 위 움짤과 같습니다.

game_example
게임 예시 이미지

게임 자체는 꽤 직관적이고 간단합니다. 화면 왼쪽 위 끝에 주어진 문제의 빈칸(_)을 화면 하단에 있는 값들로 채워 코드를 완성해 몬스터를 공격하는 방식입니다.

예를 들어 위와 같은 상황에서 플레이어가 만약 키보드의 8을 누른다면 화면 왼쪽 위 끝의 코드는 7 + 9가 될 것이고 해당 코드의 결괏값(int)이 곧 몬스터에게 이번 턴에 입히는 피해량이 됩니다.

예시로 든 7 + 9 의 결괏값은 16 이기 때문에 파이썬 로고처럼 보이는 뱀은 체력 1을 남기고 죽지 않겠네요. 이렇게 한 번에 몬스터를 무찌르지 못하면 플레이어 역시 공격을 받습니다. 각 몬스터가 얼마나 무시무시한지와 관계없이 모든 몬스터는 플레이어에게 1의 피해를 줍니다. (즉 몬스터에게 네 번 공격 당하면 플레이어의 패배로 기록됩니다)

Made with Pyxel

현장에서 게임을 즐겨주신 많은 분이 Pythonic Adventure/Challenge(이하 게임)를 어떤 게임 엔진으로 어떻게 만들었는지 궁금해하셨습니다.

이미 세상에는 좋고 편리한 게임 엔진들이 많지만 (1) 간단한 픽셀 게임을 빠르게 만들고 싶었고 (2) 게임 엔진을 익히는 게 어렵지 않고 (3) 가능하면 Python으로 만들 수 있으면 좋겠다는 이유에서 Python 기반의 오픈소스 게임 엔진 Pyxel을 이용해 게임을 만들었습니다.

import pyxel as px

from .config import Configuration, init_config


class App:

    def __init__(self, c: Configuration):
        px.init(
            Size.FRAME_WIDTH,
            Size.FRAME_HEIGHT,
            caption=App._title(c.mode)
        )

        px.load(f'assets/{c.mode.lower()}.pyxres')
        self.config = c

    def run(self):
        px.run(self.update, self.draw)

    def update(self):
        # Update your frames
        pass

    def draw(self):
        # Draw screen based on frames
        pass


config = init_config()

app = App(config)
app.run()
이렇게 간단한 코드만으로도 뭔가 프로그램이 실행됩니다

대략적인 코드 구조는 위와 같습니다. 매 프레임 실행이 되는 update 함수는 주로 게임의 진행과 관련된 로직 업데이트를 수행합니다. 예를 들어 사용자의 키 입력을 처리하거나 플레이어가 게임 오버 조건을 만족했는지 등의 논리적인 처리를 수행합니다. draw 함수는 프레임마다 실행을 보장하지 않습니다. draw 함수는 현재 게임이 실행되는 기기의 FPS에 맞춰 (일반적으로) update 보다 덜 빈번하게 호출됩니다.

class Game:

    def draw(self):
        px.cls(Color.BLACK)  # Clear Screen

        if self.status >= GameStatus.ACTIVE_SPELL:
            self.draw_active()
        elif self.status == GameStatus.INTRO:
            self.draw_intro()
        elif self.status == GameStatus.LOSE:
            self.draw_lose()  # GAME OVER
        elif self.status == GameStatus.WIN:
            self.draw_win()  # WINNER WINNER CHICKEN DINNER
이렇게 간단한 코드만으로도 뭔가 프로그램이 실행됩니다

위 코드와 같이 draw 함수는 현재 주어진 게임의 상태에 따라 수동적passive으로 화면을 그리는 역할을 담당하도록 구현했습니다.

import pyxel as px


class App:

    def draw_monster(self, monster_x, monster_y) -> None:
        px.blt(
            monster_x,
            monster_y,
            Image.MONSTER,
            self.monster.frame * Size.CHARACTER_SIZE,
            self.monster.status * Size.CHARACTER_SIZE,
            Size.CHARACTER_SIZE,
            Size.CHARACTER_SIZE,
            Color.LIGHT_GREEN,
        )
화면에 무언가를 그리는 API는 이런 형태로 제공됩니다

실제로 화면에 무언가 이미지를 그리는 Pyxel API는 위와 같은 모습으로 제공됩니다. 변수명에서도 유추할 수 있듯이 어떤 이미지 스프라이트를 어떤 위치에 어느 정도의 크기로 그릴 것인지 등을 명시할 수 있습니다.


Pyxel로 게임을 만들며 한 가지 신기하고도 뜨악했던 점은 게임에서 사용되는 대부분의 리소스를 다시 한번 Pyxel이 인식 가능한 형식으로 변환해야 한다는 점입니다. 그러나 이 형식 변환의 과정이 매우 무시무시했는데요.

pyxel_map_tool
Pyxel에서 제공하는 타일 맵 제작 도구

예를 들어 2D 게임에서 종종 사용되는 타일맵tile map 기반 맵을 제작하기 위해서는 반드시 Pyxel에서 제공하는 스프라이트 에디터를 이용해 .png파일을 .pyxres 파일로 변환해야 합니다.

그러나… 진짜 진짜는 바로 음악이었습니다.

import pyxel


class PlayerAttack:

    def __init__(self):
        pyxel.sound(3).set(
            note="b4b4b4a2 c2c2c2c2 c2c2c2c2 c2c2c2c2",
            tone="s",
            volume="7766 5566 7766 5531",
            effect="s",
            speed=5,
        )

    def get_soundlist(self):
        return {3: 3}
보이시나요… 저 note 문자열이…

Pyxel에서는 음악을 일반적인 게임 개발에서 사용되는 .wav 등의 파일을 사용하지 않고 재생할 음표note를 표현한 문자열을 기반으로 음악을 다룹니다…

pyxel_music_tool
Pyxel에서 제공하는 음악 찍기 도구

효과음 및 배경 음악 등의 소리를 Pyxel에서 재생하기 위해서는 위와 같은 도구를 활용해 위에 첨부한 코드와 같은 형태로 만들어야 합니다. 사실상 음표 문자열 만들기를 도와주는 도구라고 볼 수 있습니다. 이 사실을 알게 된 저희 팀은 파이콘 하루 전, 금요일 밤, 음악과 함께 밤을 불태웠어요. (저희의 게임에 어울릴만한 다른 게임 BGM 악보를 찾아서 한 땀 한 땀 문자열로 옮겼습니다)

member
금요일 밤을 불태운 사람들
import pyxel


class BGMBattle:

    def __init__(self):
        mute_bar = "rrrr rrrr rrrr rrrr"

        pyxel.sound(32).set(
            f"""{mute_bar}
            g2f#2f#2f#2 f#2f#2f#2f#2 f#2f#2f#2f#2 g2g2a#2g2
            f#2f#2f#2f#2 f#2f#2f#2f#2 f#2f#2f#2f#2 rrrr
            c#3b2b2b2 b2b2b2b2 b2b2b2b2 c3c3d#3c3
            b2b2b2b2 b2b2b2b2 b2b2b2b2 rrrr
            f#2f#2f#2f#2 f#2f#2f#2f#2 f#2f#2f#2f#2 g2g2a#2g2
            f#2f#2f#2f#2 f#2f#2f#2f#2 f#2f#2f#2f#2 a#2a#2d3a#2
            f#2f#2f#2r f#2f#2a#2f#2 d2d2d2r d2d2f#2d2
            b1b1b1b1 b1b1b1b1 b1b1b1b1 a#1a#1d2a#1
            f#1f#1f#1f#1 f#1f#1a#1f#1 d1d1d1d1 d1d1f#1d1
            b0b0b0b0 b0b0b0b0 b0b0b0b0 rrrr
            c#3c#3c#3c#3 c#3c#3c#3c#3 c#3c#3c#3c#3 a#2a#2c#3a#2
            b2b2b2b2 b2b2b2b2 b2b2b2b2 f#1f#1b1e2
            d2d2b1f#1 b1b1d2g2 f#2f#2d2b1 c2c2f#2a#2
            b2b2b2b2 b2b2b2b2 b2b2b2b2 rrrr
            f2e2e2e2 e2e2e2e2 e2e2e2e2 f2f2g#2f2
            e2e2e2e2 e2e2e2e2 e2e2e2e2 rrrr
            b-2a2a2a2 a2a2a2a2 a2a2a2a2 a2a2c#3a2
            a2a2a2a2 a2a2a2a2 a2a2a2a2 rrrr
            e2e2e2e2 e2e2e2e2 e2e2e2e2 f2f2g#2f2
            e2e2e2e2 e2e2e2e2 e2e2e2e2 g#2g#2c3g#2
            e2e2e2e2 e2e2g#2e2e2 c2c2c2c2 c2c2e2c2
            a1a1a1a1 a1a1a1a1 a1a1a1a1 g#1g#1c2g#1
            e1e1e1e1 e1e1g#1e1 c1c1c1r c1c1e1c1
            a0a0a0a0 a0a0a0a0 a0a0a0a0 rrrr
            b2b2b2b2 b2b2b2b2 b2b2b2b2 g#2g#2b2g#2
            a2a2a2a2 a2a2a2a2 a2a2a2a2 e1e1a1d2
            c2c2a1e1 a1a1c2f2 e2e2c2a1 b1b1e2g#2
            a2a2a2a2 a2a2a2a2 a2a2a2a2 rrrr
            c#2c#2c#2c#2 c#2c#2c#2c#2c#2 c#3c#3c#3c#3 c#3c#3c#3c#3
            f#2f#2f#2f#2 f#2f#2f#2f#2 f#2f#2f#2f#2 f#2f#2f#2f#2
            """.replace(
                "\n", ""
            ),
            "t",
            "6",
            "n",
            10,
        )
그렇게 코드로 옮겨진 페르시아의 왕자 BGM 중 일부 (이런 문자열 덩어리가 세 개는 더 있습니다…)

Level Design

게임에서 중요한 것 중 하나는 역시 레벨 디자인이고, 저희가 준비한 Pythonic Adventure와 Challenge에서의 레벨 디자인은 결국 곧 Python 문제들을 잘 선정해 적당한 난이도의 몬스터 체력을 설정하는 일입니다.

이를 위해서는 문제마다 사용자가 만들어낼 수 있는 수식의 결괏값 범위를 정확히 알아야만 했습니다. 물론 문제를 만드는 저희가 어림짐작으로 min, max 케이스를 만들어낼 수는 있겠지만 틀릴 가능성이 있기 때문에 좀 더 확실히 하고자 다음과 같은 스크립트를 만들어 각 문제를 검증했습니다. (그리고 실제로 그런 사례가 있었습니다 👀)

from itertools import permutations


def permutate(cards, n, func):
    for p in permutations(cards, n):
        try:
            yield eval(func(p)), p
        except Exception as e:
            pass


def only_numbers(result):
    value, p = result
    return isinstance(value, (int, float))


def calc(cards, n=None, pre_format=None):
    if n is None:
        n = len(cards)
    if pre_format is None:
        pre_format = '{}' * n

    func = lambda p: pre_format.format(*p)
    seq = list(filter(only_numbers, permutate(cards, n, func)))
    if not seq:
        print('Cannot calculate!')
        return

    for m_func in (min, max):
        value, p = m_func(seq)
        print(func(p), '→', value)

    available = sorted(set(v for v, _ in seq))
    print('available:', available)
from calculator import calc


if __name__ == '__main__':
    cards = ['<', '>', '10', '30', '50', '70', '90', 'i', '==']
    pre_format = 'sum([int(i {} {}) for i in range({}, {})])'
    calc(cards, n=4, pre_format=pre_format)

    cards = ['0', '0.1', '0.5', '1', '2', '3', '4', 'x', 'y']
    pre_format = '{} ** len(str(sum({} for _ in range({}))))'
    calc(cards, n=3, pre_format=pre_format)

    cards = ['bin', '4', '"10101"', '2', '16', '8', '"0xff"', '"0o76"', '0']
    pre_format = 'int({}, {})'
    calc(cards, n=2, pre_format=pre_format)

    cards = ['2', '3', '4', '*', '*', '"1"', '"2"', '"3"', '"4"']
    calc(cards, n=5)

    cards = ['3', '5', '*', 'sum', 'range']
    pre_format = '{}(x{}{} for x in {}({}))'
    calc(cards, pre_format=pre_format)

    cards = ['ord', 'min', 'len', '"banksalad"', '"pycon"', '*']
    pre_format = '{}({}({})){}{}({})'
    calc(cards, pre_format=pre_format)

    cards = ['sum', 'range', '+', '*', '2', '4', '8']
    pre_format = '-pow({}({}({})), {})'
    calc(cards, n=4, pre_format=pre_format)

    cards = ['+', '-', '*', '/', '7', '6', '5', '4', 'None']
    calc(cards, n=5)
위와 같이 간단한 스크립트를 작성해 각 문제를 설계하는데 유용히 활용했습니다 😎

이렇게 각 문제의 답안이 나올 수 있는 범위를 바탕으로 챌린저 난이도는 (거의) 가장 좋은 답안이 아니면 한 번에 몬스터를 무찌를 수 없는 값으로 몬스터의 체력을 설정했습니다.


위와 같은 고민과 노력 끝에 탄생한 (난이도 조절에 실패한) 1일 차와 2일 차 문제 중 많은 분이 질문해주셨던 몇 가지 문제를 살펴보는 것으로 글을 마치려고 합니다.

Pythonic Adventure Explained

first_day_stage2
1일 차 Adventure 스테이지 2

1일차 Adventure 스테이지 2의 문제입니다. 저희의 예상보다 많은 분이 헷갈리셨던 문제인데요. Python의 boolean evaluation을 잘 알고있다면 어렵지 않게 해결할 수 있는 문제입니다. 가장 큰 피해량인 4를 만들기 위해서는 두 개의 False 와 하나의 True 를 입력하면 됩니다. 주어진 보기에서는 ” 와 NoneFalse 가 되고 나머지 모든 보기는 True 가 됩니다. 예를 들어 lendir 등의 내장 함수 역시 True 가 됩니다.

first_day_stage3
1일 차 Adventure 스테이지 3

다음은 많은 분이 멘붕하신 1일차 Adventure 스테이지 3의 문제입니다. 9개의 보기 중 7개를 골라 144의 피해를 입혀야 하는 문제인데요. lenstr 이 주어진 유일한 함수이니 len(str(_ _ _)) _ _ 형태일 것으로 추측할 수 있습니다. 그렇다면 str 을 통해 가장 긴 문자열을 만들어내는 게 핵심일 것 같습니다. 어떤 값들을 활용해야 가장 긴 문자열을 만들 수 있을까요?

(두구두구… 🥁) 정답은 바로 1 / 3 입니다. 무한소수 형태로 만들 때 18 의 길이를 갖게 됩니다. 이는 Python의 내장 repr 함수는 유효 숫자로 17개의 숫자를 취하기 때문입니다. 따라서 위 문제는 len(str(1 / 3)) * 8 이 가장 큰 값(144)을 갖게 됩니다.

Pythonic Challenge Explained

first_day_challenge_stage2
1일 차 Challenge 스테이지 2

많은 분이 내가 알던 int 가 아니라고 말씀하셨던 논란의 1일 차 Challenge 스테이지 2의 문제입니다. 내장 함수 int 의 두 번째 인자(base)는 숫자 문자열의 진법을 나타냅니다. 예를 들어 int('10', 2) 는 이진수 10 을 나타내는 것이기 때문에 십진수2 가 됩니다. 이렇게 진법을 활용하면 int('10101', 16) 은 십진수로 65793 해당하고 최대의 피해를 줄 수 있습니다.

first_day_challenge_stage3
1일 차 Challenge 스테이지 3

산 넘어 산이라고 역시나 많은 분을 멘붕에 빠뜨린 1일 차 Challenge의 마지막 문제입니다. 이미 ** 가 문제에 포함되어 있으니 가장 큰 값을 앞에 배치하고 뒤의 값(len(str(sum(...))))을 가능한 한 크게 만드는 게 핵심 전략이 될 것 같습니다.

먼저 정답을 공개하면 0.1 + 0.1 + 0.1의 형태를 만드는 게 가장 긴 문자열을 만드는 방법입니다. 재밌게도 위 연산의 결과는 0.30000000000000004가 됩니다. 그래서 이를 이용하면 4 ** len(str(sum(0.1 for x in range(3)))) 이 가장 큰 값인 4 ** 19가 됩니다.

왜 이런 결과가 생기는지 이 글에서 직접 다루기엔 곤란하여 좀 더 궁금하신 분은 IEEE 754를 찾아보시거나 https://0.30000000000000004.com 사이트를 살펴보시면 도움이 될 것 같습니다 😃 이런 상황을 피하기 위해서는 파이썬의 decimal 모듈을 사용하면 됩니다 (해당 모듈은 C로 구현되어 연산 성능의 이점이 있습니다)

second_day_challenge_stage2
2일 차 Challenge 스테이지 2

다음은 2일 차에 많은 분이 궁금해하셨던 divmod 에 관한 문제입니다. 처음 보셨다는 분들이 꽤 많았는데요. divmod(a, b)(a // b, a % b) 을 반환하는 함수입니다. 따라서 최댓값인 35를 만들기 위한 코드는 divmod(35, 1)[0] 가 되겠습니다.

second_day_challenge_stage3
2일 차 Challenge 스테이지 3

마지막으로 Python의 큰 특징, for comprehension을 이용한 문제입니다. listsum을 구하는 형태이니 해당 list를 잘 구성하는 게 중요한 문제일 것 같습니다. 다만 활용할 수 있는 연산자가 논리연산자(==, >, <) 뿐이고 이를 int 함수를 통해 숫자로 변환하기 때문에 최대한 많은 값이 True가 되는 게 중요할 것 같습니다. 이런 점을 모두 고려할 때 sum([int(i == i) for i in range(10, 90)])이 가장 많은 True를 갖는 list가 됩니다.

마치며

booth2
저희 흥했어요! (1)

두근두근 파이콘 행사 당일, (다행히도) 많은 분이 부스를 방문해주시고 저희가 준비한 프로그램을 즐겨주셨습니다.

booth3
저희 흥했어요! (2)

특히 첫째 날 Pythonic Adventure/Challenge를 즐기기 위한 줄이 매우 길었는데, 다들 재밌게 즐겨주시고 잘 참여해주셔서 감사합니다. 예상보다 많은 분이 부스를 찾아주셔서 다소 저희의 진행이 미숙했던 점이 있을 텐데 다들 잘 이해해주셔서 감사합니다. 다음 행사에는 좀 더 잘 준비해 참여하겠습니다. 감사합니다.


보다 빠르게 뱅크샐러드에 도달하는 방법 🚀

지원하기
Share This: