플레이어블 광고 만들기(feat. Phaser.js with Applovin)

김현조·2023년 5월 17일
5
post-thumbnail

플레이어블 광고란?

유저가 직접 인터렉션하며 게임을 해야 진행되는 광고를 본적이 있다면, 플레이어블 광고를 이미 경험해본 것이다!

플레이어블 광고는 앱을 설치하기 전에 해당 게임을 경험해볼 수 있도록 기회를 제공하는 광고이다. 일반적인 광고와 다르게 유저의 인터렉션을 통해 광고가 진행되는 것이 특징이며 빠른 시간 내에 게임의 핵심 내용을 담아내야한다.

플레이어블 광고 개발 방법

플레이어블 광고는 간단하게 웹에서 동작하는 게임이라 볼 수 있으며 Canvas API를 활용하여 만들 수 있다. 다만 게임으로서 역할하기 위한 물리 기능(객체끼리 부딪히는 경우, 오버랩되는 경우 등…)이나 애니메이션, 카메라 이동 등을 모두 직접 구현하는 것은 어렵기 때문에 Phaser.js라는 JavaScript 기반 게임개발 프레임워크를 사용하여 개발하는 것이 추천된다. 이외에도 Google Web Designer, Applovin의 SparkLab 등에서도 노코드로 제작이 가능하다.

Applovin이란?

Applovin은 사용자 맞춤형 광고를 게시해주는 솔루션을 제공하는 모바일 기술 회사이다. 최종 QA 및 배포 담당으로 볼 수 있겠다. 실제로 Applovin에서 규정한 5MB 이하의 파일 크기, 세로/가로 모드 모두 지원, 최소 1개 이상의 CTA(Call to Action) 등의 기준을 따라야하므로 플레이어블 광고 개발에서 중요한 역할을 한다.

이번 포스트에서는 Phaser.js로 직접 플레이어블 광고를 개발하는 방법과 몇가지 팁을 살펴보도록 하겠다.

Phaser.js + Applovin (feat. base64)

Phaser.js와 Applovin이 이미지, 오디오 등의 asset을 바라보는 관점은 전혀 다르다. Phaser는 게임, Applovin은 광고를 위해 asset을 사용하기 때문이다. 게임의 경우 asset이 무수히 많은 것은 너무나도 당연한 일이며 유저들도 그 사실을 알고 있다. 따라서 로딩 시간이 일반 웹사이트나 가벼운 앱보다 느리더라도 이를 자연스럽게 받아들인다. 그러나 광고는 다르다. 광고는 유저가 이탈하기 전에 최대한 빠르게 빠짐없이 모든 정보를 보여주어야 한다. 따라서 Applovin은 플레이어블 광고에서 추가적인 네트워크 요청을 허가하지 않는다. 이는 네트워크 요청에서 발생할 수 있는 예상치 못한 외부 오류 요인을 없애기 위함이다. 그러나 외부에서 이미지를 받아올 수 없다면 도대체 어떻게 사용해야 할까? 답은 base64이다.

Base64는 8비트 이진 데이터를 문자 코드에 영향을 받지 않는 공통 ASCII 영역의 문자들로만 이루어진 일련의 문자열로 바꾸는 인코딩 방식을 가리킨다. 문자 그대로 해석하면 “64진법”이라는 뜻이다. 일반적인 방식을 통해 base64로 인코딩을 하게 되면 6bit 당 2bit의 오버헤드가 발생하여 전송해야할 데이터의 크기가 약 33%정도 증가하게 된다. 해당 단점을 안고도 이를 사용하는 이유는 플랫폼 독립적으로 데이터를 사용해야 할때, 통신 과정에서의 데이터 손실을 막기 위함이다.

Phaser.js 프로젝트를 webpack을 이용해 세팅했다면 webpack의 url-loader를 활용하여 base64로 인코딩하면 쉽게 해결될 것처럼 보인다. 이는 Phaser.js의 입장을 들어보지 않았을 때의 이야기이다. 게임 개발을 목적으로 하는 Phaser.js는 base64의 사용을 지양한다.

실제 Phaser.js 개발자의 한탄: “게임 개발에 base64 에셋을 사용하는 것은 정말 좋지 않은 생각입니다!”

따라서 일반적인 에셋 로딩 방식으로는 Local data URIs are not supported 라는 에러 메시지를 확인하게 될 것이다. 따라서 다른 방식으로 우회하여 base64 에셋을 사용해야 한다.

이미지

이미지를 Texture Manager에 직접 넣어줄 수 있는 addBase64 메서드를 활용하여 아래와 같이 작성한다.

//LoadingScene.js

import background from "../assets/ui/background.jpeg";
import button from "../assets/ui/button.png";

this.images = [{ key: "background", image: background }, { key: "button", image: button }];

this.images.forEach(({ key, image }) =>
	this.textures.addBase64(key, image)
);

스프라이트시트

스프라이트시트를 Texture Manager에 직접 넣어줄 수 있는 addSpriteSheet 메서드를 활용하여 아래와 같은 유틸 함수를 만들 수 있다.

function preloadSpriteSheet({ key, imgSrc, config }) {
    const image = new Image();
    image.onload = () => {
      this.textures.addSpriteSheet(key, image, config);
    };
    image.src = imgSrc;
}

해당 함수를 활용해 LoadingScene의 preload 메서드에 아래와 같이 작성해줄 수 있다.

//LoadingScene.js

import luckEffect from "../assets/spritesheet/luck_effect.webp";

this.spriteSheets = [{
        key: "luck_effect",
        imgSrc: luckEffect,
        config: {
          frameWidth: 295, // 프레임 한개당 width
          frameHeight: 250, // 프레임 한개당 height
          startFrame: 0, // 스프라이트 시트 시작 프레임(보통 0)
          endFrame: 4, // 스프라이트 시트 끝 프레임(보통 프레임 개수-1)
        },
      }];

preload() {
    this.spriteSheets.forEach((params) => {
      this.preloadSpriteSheet(params);
    });
  }

오디오

오디오의 경우는 조금 헷갈릴 수 있다. 오디오를 단순히 mp3로만 첨부하는 것이 아니라 mp3와 JSON으로 구성된 오디오 스프라이트를 생성해야 한다. Audio Sprite Surfer에서 mp3를 업로드하면 오디오 스프라이트를 위한 JSON을 생성할 수 있다. 이후 Phaser.js에서 제공하는 base64 파일을 ArrayBuffer로 디코딩해주는 메서드인 Base64ToArrayBuffer 를 활용한 아래 유틸 함수를 작성한다.

import Phaser from "phaser";

export const loadAudio = (key, audio, json, scene) => {
  const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  audioContext.decodeAudioData(
    Phaser.Utils.Base64.Base64ToArrayBuffer(audio),
    (buffer) => {
      scene.cache.audio.add(key, buffer);
    },
    (e) => {
      console.log("Error with decoding audio data" + e.err);
    }
  );

  scene.cache.json.add(key, json);
};

위의 유틸 함수를 활용하여 LoadingScene의 preload 메서드안에 아래와 같이 작성해주면 된다.

//LoadingScene.js

import clickAudio from "../assets/audio/click.mp3";
import clickJson from "../assets/audio/click.json";

preload() {
	loadAudio(KEY.AUDIO.CLICK, clickAudio, clickJson, this);
}

위와 같이 잘 작성했는데도 오디오가 제대로 재생이 되지 않는 경우가 있을 수 있다. 이는 loadAudio 메서드를 자세히 살펴보면 이유를 알 수 있다. loadAudio는 base64파일을 디코딩해주기 때문에 큰 파일의 경우 시간이 꽤 소요될 수 있다. 따라서 해당 메서드가 동기적으로 동작할 것이라 예상하고 코드를 작성하게 되면 해당 오디오 파일을 play하는 순간에 디코딩된 오디오 파일이 없어 play가 되지 않을 수 있다. 이를 해결하기 위해서 EventEmitter를 정의할 수 있다.

Phaser.js에서 제공하는 EventEmitter를 extend하여 Custom EventEmitter를 아래와 같이 정의한다. 이때 주의할 점은 eventEmitter를 Singleton 패턴으로 생성해야 예상치 못한 동작에서 벗어나 원하는대로 이벤트를 던지고 받기에 용이하다는 점이다.

import Phaser from "phaser";

let instance = null;

class EventDispatcher extends Phaser.Events.EventEmitter {
  constructor() {
    super();
  }

  static getInstance() {
    if (instance == null) {
      instance = new EventDispatcher();
    }
    return instance;
  }
}

export default EventDispatcher;

이제 해당 eventEmitter를 활용하여 디코딩이 완료되었다는 이벤트를 던져보자. 이전의 loadAudio 메서드에서 scene.eventEmitter.emit("decoded" + key); 가 추가되었다. 이는 해당 key의 오디오 디코딩이 완료되면 이벤트를 던지겠다는 뜻이다.

import Phaser from "phaser";

export const loadAudio = (key, audio, json, scene) => {
  const audioContext = new (window.AudioContext || window.webkitAudioContext)();
  audioContext.decodeAudioData(
    Phaser.Utils.Base64.Base64ToArrayBuffer(audio),
    (buffer) => {
      scene.cache.audio.add(key, buffer);
      **scene.eventEmitter.emit("decoded" + key);**
    },
    (e) => {
      console.log("Error with decoding audio data" + e.err);
    }
  );

  scene.cache.json.add(key, json);
};

이벤트를 던졌으니 받아주는 쪽의 코드를 작성해보자. 실제로 게임이 진행되는 Scene의 create 메서드에 추가해주면 된다. 여기서는 유일하게 1분 이상의 오디오인 BGM에 대해 이벤트를 받았다.

// PlayingScene.js

create() {
	this.eventEmitter.on("decoded" + KEY.AUDIO.BGM, () => {
      this.audio.bgm = this.sound.add(KEY.AUDIO.BGM, { loop: true });
      this.input.once("pointerdown", () => this.audio.bgm.play());
  });
}

이러한 과정을 통해 이미지, 스프라이트시트, 오디오에 대한 Phaser.js와 Applovin의 대립을 해결해볼 수 있었다! 플레이어블 광고는 다른 광고 유형에 비해 유입 대비 잔류율이 높아져 진성 유저가 많아지는 효과를 낳는다. 이렇게 효과적인 플레이어블 광고 시장의 확대와 개발시 기술적 허들의 감소를 기대하며 포스트를 마친다.

2개의 댓글

comment-user-thumbnail
2023년 5월 18일

대박! 이렇게 멋진건 또 언제 배워서 또 언제 이렇게 쓰신거에요!!! 멋져요!!

1개의 답글