Сохранение и загрузка пользовательских уровней

Всем еще раз привет :-)

Сегодня я расскажу как делал сохранение уровней на сервере.

Изначально в моей игре для целей разработки игры был сделан редактор уровней, собственно он был сделан раньше всего остального, игра была сделана как совмещенный редактор и игра, потом уже я их разделял примерно также, как хирург разделяет сиамских близнецов.

Это наверное не имеет отношения к сохранению уровней на сервер, но я расскажу :-) Объекты каждого типа у меня лежат внутри объекта типа Sprite, которые уже лежат внутри общего спрайта игры. Сохранение уровней это обход каждого из этих спрайтов с помощью замечательных функций numChildren и getChildAt и запись параметров объектов в xml. После этого с помощью прекрасных функций объекта ByteArray writeUTFBytes и compress, а также надыбанной где-то по блогам библиотеки as3base64 получается не очень большая, но очень страшная вид строка.

Загрузить эту строчку совсем не сложно. Надо ее обратно раскодировать, разжать и распарсить в xml, а потом обойти все узлы и создать соответствующие объекты.

В любом случае у вас это может быть и как-то по другому сделано.

Теперь собственно к сути. Когда я понял, что мне хочется отдать редактор в пользование людям, мне стало также ясно и то, что отдавать его с сохранением только в такую строчку, которую юзер может себе где-то заныкать — это не дело. И тогда я стал думать как быть. Своего сервера у меня не то, что нет: есть дешевый шаред хостинг, но нагружать его хранением уровнями я не хотел, он мог бы и не выдержать, или денег начать просить. Заводить свой сервер ради этого тоже не хотелось. Можно было ждать спонсора предлагать ему сделать, обсуждать его апи и т.д. — все очень заморочено и с большой вероятностью вообще не выйдет.

На всякий случай для слабых духом сообщаю: есть такой playtomic.com, бывший swfstats, там есть сервис по хранению уровней. И рейтингов к этим уровням, и все для броузинга по этим уровням. Пока бесплатно, но могут начать брать деньги в любой момент. Короче знайте, если не хотите делать сами — варианты есть :-)

Я же испугался возможной платности, к тому же было интересно сделать самому. Давно заглядывался на Google App Engine. По моим прикидкам нагрузка должна уложится в бесплатные квоты: особой популярности сохранения и игры в пользовательские уровни я не ожидаю. Да и вообще очень уж какой-то популярности своей игры не жду к сожалению, слишком много думать надо. Дайте мне другой народ, как говорится :-)

В общем зарегистрировался я на сайте. Спросили телефон, прислали смс. Скачал SDK. Заставили поставить python 2.5. Он перекрыл собой стоявший уже 2.6 :-( теперь все скрипты запускаются в 2.5. Я пока не разбирался что с этим делать. Но пару скриптов уже пришлось исправлять, т.к. использовались вкусняшки, которых нет в 2.5. Вот такой я ленивый.

SDK сразу включает в себя красивую панельку, из которой можно создать приложение, запустить сервер, загрузить приложение в гугл. Полез на сайт в помощь. Взял какой-то самый первый пример и переделал под себя. Читайте код и коменты.


from google.appengine.ext import webapp
from google.appengine.ext.webapp.util import run_wsgi_app
from google.appengine.ext import db
import random
import string
from datetime import datetime

#Описание полей таблицы базы данных, она у нас одна пока
class Levels(db.Model):
  code = db.StringProperty() # Уникальная часть URL. 5 случайных символов плюс id. Случайные символы нужны, чтоб нельзя было просто перебирать номера.
  dateAdded = db.DateTimeProperty() # дата добавления
  lastDatePlayed = db.DateTimeProperty() # дата игры, обновляется при каждой загрузке
  plays = db.IntegerProperty() # количество игр
  data = db.TextProperty() # собственно данные уровня

#Зовем каждый раз, когда нам нечего сказать. Возможно это и не так положено делать.
def Error404(response):
  response.set_status(404)
  #response.out.write("Nothing here!!!")
  return
  
#По коду (см.описание выше) находим уровень в бд. Отрезаем 5 символов и ищем по id, потом сравниваем 5 символов.
def GetLevelByCode(code):
  id = int(code[5:])
  level = Levels.get_by_id(id)
  if level is None or level.code != code:
    return
  else:
    return level
  
#Это форма для отладки сохранения/загрузки. Все прозрачно.    
class TestForm(webapp.RequestHandler):
  def get(self):
    self.response.out.write("""
<form action="/savelevel" method="post">
  <input type="text" name="data">
  
  <input type="submit" value="Send">
</form>

<form action="/loadlevel" method="post">
  <input type="text" name="code">
  
  <input type="submit" value="Send">
</form>
""")

#О, это большая хитрость. Чтобы можно было загружать контент из флеша, сервер должен это разрешить. 
class CrossDomainXML(webapp.RequestHandler):
  def get(self):
    self.response.out.write("""<?xml version="1.0"?><cross-domain-policy><allow-access-from domain="*" /></cross-domain-policy>""")    

#Класс, который используется для сохранения уровней. Его методы вызываются при запросе с url "/savelevel"
class SaveLevel(webapp.RequestHandler):
  #GET запросы не обслуживаем, чтоб школьники (и здесь они) из браузера не баловались
  def get(self):
    Error404(self.response)
  
  #POST запрос. Ожидается переменная data. Выдает код уровня на выход.
  def post(self):
  
    data = self.request.get("data")
  
    if len(data) == 0 or len(data) > 2000:
      return Error404(self.response)
  
    level = Levels()
    level.data = data
    level.dateAdded = datetime.now()   
    level.plays = 0
    level.put();
    level.code = "".join([random.choice(string.letters) for x in xrange(5)]) + str(level.key().id())
    level.put();
    self.response.out.write(level.code);
    
#Класс для загрузки уровней. URL - "/loadlevel"
class LoadLevel(webapp.RequestHandler):
  def get(self):
    Error404(self.response)
  
  #Смотрим переменную code, находим уровень, обновляем дату и количество игр, выдаем уровень на выход
  def post(self):
    code = self.request.get("code")
  
    if code is None or len(code) == 0:
      return Error404(self.response)
      
    level = GetLevelByCode(code)
    if level is None:
      return Error404(self.response)
    self.response.out.write(level.data);
    level.plays = level.plays + 1
    level.lastDatePlayed = datetime.now()
    level.put()
    
#Всякие настройки. Всё интуитивно понятно :-)
application = webapp.WSGIApplication(
                                     [
                                     ('/testform', TestForm),
                                     ('/savelevel', SaveLevel),
                                     ('/loadlevel', LoadLevel),
                                     ('/crossdomain.xml', CrossDomainXML)
                                     ],
                                     debug=True)
def main():
  random.seed()
  run_wsgi_app(application)

if __name__ == "__main__":
  main()


А в as3-проекте я сделал специальный класс-окошко, который делает запросы. Там используется мой класс SimpleUI, что он делает там везде понятно, показывать его не буду: он не хорош :-)

package  
{
        import flash.display.*;
        import flash.events.*;
        import flash.net.*;
        
        public class LevelsRequest extends Sprite
        {
                private var textObj:DisplayObject;
                private var loader:URLLoader;
                private var url:String;
                private var vars:Object;
                private var onDone:Function;
                private var onCancel:Function
                
                public function LevelsRequest(url:String, vars:Object, text:String, onDone:Function, onCancel:Function = null) 
                {
                        this.url = url;
                        this.vars = vars;
                        this.onDone = onDone;
                        this.onCancel = onCancel;
                        
                        textObj = SimpleUI.Text( { width:Main.appWidth, border: 5, text: text } );
                        
                        addChild(SimpleUI.Container( {
                                width: Main.appWidth,
                                height: Main.appHeight,
                                background: 0x808080,
                                alpha: 0.8,
                                children: [
                                        textObj, 
                                        SimpleUI.Button( { text: "Cancel", action: Cancel } )
                                        ]
                                }));
                                
                        var request:URLRequest = new URLRequest(Game.levelsDataURL + url);
                        
                        var urlVars:URLVariables = new URLVariables();
                        for (var name:String in vars)
                                urlVars[name] = vars[name];
                        
                        request.data = urlVars;
                        request.method = URLRequestMethod.POST;
                        loader = new URLLoader();
                        loader.addEventListener(Event.COMPLETE, OnLoaderComplete);
                        loader.addEventListener(IOErrorEvent.IO_ERROR, OnError);
                        loader.addEventListener(SecurityErrorEvent.SECURITY_ERROR, OnError);
                        loader.load(request);
                        
                        addEventListener(Event.REMOVED_FROM_STAGE, OnRemovedFromStage);
                }
                
                private function OnLoaderComplete(e:Event):void
                {
                        if(onDone != null)
                                onDone(loader.data);
                        if(parent)
                                parent.removeChild(this);
                }
                
                private function OnError(e:Event):void
                {
                        SimpleUI.SetText(textObj, "An error occured. Sorry :-( " + e.toString());
                }
                
                private function OnRemovedFromStage(e:Event):void
                {
                        try {
                                loader.close();
                        }
                        catch (e:*)
                        {}
                        removeEventListener(Event.REMOVED_FROM_STAGE, OnRemovedFromStage);
                        loader.removeEventListener(IOErrorEvent.IO_ERROR, OnError);
                        loader.removeEventListener(SecurityErrorEvent.SECURITY_ERROR, OnError);
                        loader.removeEventListener(Event.COMPLETE, OnLoaderComplete);
                }
                
                private function Cancel():void
                {
                        if (onCancel != null)
                                onCancel();
                        if(parent)
                                parent.removeChild(this);
                }
        }
        
}


Пример вызова этого класса такой:

//загрузка
function StartCustomGame(levelData:String):void {}
function ShowMainMenu():void {}
addChild(new LevelsRequest("/loadlevel", { code:levelcode }, "Loading level...", StartCustomGame, ShowMainMenu));

//сохранение
function ShowTextBoxWithURL(levelCode:String):void { /*здесь что-то типа MessageBox("sponsor.com/game?level="+levelCode) */}
var levelData:String;
addChild(new LevelsRequest("/savelevel", { data:levelData }, "Saving your level", ShowTextBoxWithURL);


Почти всё! Но есть маленькая проблема: как нам узнать, что пользователь хочет загрузить уровень? Можно уповать на то, что удастся договориться со спонсором и он сам распарсит URL и поместит код во flashVars, откуда мы его достанем. Но не будем ждать милостей от природы: на просторах интернета учат, что есть способ получить url страницы, на которой заэмбеден наш swf. Способ прост как три копейки:

var pageURL:String = ExternalInterface.call('eval', 'window.location.href');

Только в try/catch обернуть — и готово. Потом надо просто этот pageURL при старте флешки распарсить:

var re:RegExp = /loadlevel=([a-zA-Z0-9]+)/;
var result:Array = pageURL.match(re);
if (result == null || result.length != 2)
//нормальный запуск игры
else
//загрузка уровня, код которого в result[1]


За сим прощаюсь. Но не навсегда :-)

P.S. Полезное дополнение от Claymore:
Без участия спонсора может и не получится достать Url, в коде встраивания флешки доступ к выполнению скриптов должен быть разрешён
<param name="allowScriptAccess" value="always"></param>

И во флэшке не забываем прописывать
Security.allowDomain(имя_домена)
  • +22

Комментарии (28)

0
Спасибо, думаю полезно. Плюс.
0
«Теперь собственно к сути. Когда я понял, что мне хочется отдать редактор в пользование людям, мне стало также ясно и то, что отдавать его с сохранением только в такую строчку, которую юзер может себе где-то заныкать — это не дело.»
Что не так?
0
Не будут люди пользоваться, я бы не стал. Точнее будут, но надо дать им простой способ показать сделанное другим.
На самом деле надо делать и внутриигровой браузер и рейтинги, чтоб действительно выхлоп был.
0
Да, полезная инфа — плюсанул. На счет playtomic, Бено сказал, что собирается брать деньги со спонсоров, за услуги поиска игр по популярности и т.п. А девелоперов он пока заставлять платить не собирается. Даже если через год станет платным то там цена мизерная будет, так что можно и пользоваться :)
  • DSoul
  • DSoul
0
Моей главной мотивацией было любопытство :-)
И свобода: например у него по-моему нет возможности прикрутить таблицу рекордов к уровню. А я легко это сделаю.
0
я встречал упоминания о таком способе сохранения уровней, но соответствующий код для Google App Engine вижу впервые. так что спасибо за пост. плюсанул.
0
за пост плюс, питон это отлично :)

p.s. sdk просто ставить отдельно нужно. на какой системе делалось? под nix масса вариантов как для каждого проекта иметь свое окружение :)
0
Windows. Если б он при установке спросил, мне отдельно или вместо, я бы ответил. Я ждал, что он найдет другой питон, но он и не подумал.
0
я так и думал :)
тут только читать что пишут и ставить руками все.
инсталляторы для nix софта под win очень часто слабо сделаны.
на свежих nix системах уже везде 2.6 в комплекте, другое дело что когда нужно несколько версий, то нужно ручками дописывать,
но для nix это штатный режим настройки и работы :)
0
Сам чуть позже хотел рассказать про gravistation.appspot.com — сайт для хранения уровней на GAE — опередил. (:
Вообще GAE — крутая штука, всем рекомендую к ознакомлению. За два последних куска кода — спасибо, думал придётся спонсора просить.
+1
В большой семье клювом не щелкай :-)Я думаю тебе в этом контексте все равно найдется что сказать.
0
Не, это как раз хорошё, что не я один этим занимаюсь, можно опытом делиться. (:
+2
Интересный пост. Без участия спонсора может и не получится достать Url, в коде встраивания флешки доступ к выполнению скриптов должен быть разрешён
<param name="allowScriptAccess" value="always"></param>
И во флэшке не забываем прописывать
Security.allowDomain(имя_домена)
0
О, об этом я не подумал, спасибо.
Но все равно, одно дело ждать спонсора и ничего не иметь, а другое попросить его конкретную и небольшую вещь подправить на страничке.
0
Это то само-собой. Но спонсор может на это не пойти из соображений безопасности. Более прозрачно для него будет, если ты подготовишь для этих целей скрипт на JS или PHP, чем открывать тебе полный доступ к JS. Да и в любом случае ведь придется внедрять на сайт спонсора скрипт, который получает список уровней с твоего хранилища. Или навигацию по доп. уровням переносить полностью в флэшку, тогда вопросы интеграции со спонсором отпадают))
0
Если не пойдет, то переделать на flashvars — дело пяти минут :-). Но зато уже возможность продемонстирована.

Навигацию по доп.уровням мне лично проще делать во флешке: веб девелопер из меня скорее теоретический, а As3 уже более-менее освоил. Так я и планирую скорее всего, но уже после того как найдется спонсор, и если его это заинтересует.
0
Я тут сейчас подумал… Спонсору выгоднее когда навигация по доп. уровням находится на сайте. Примерно так: есть страница с превьюхами и ссылками на уровни (система рейтинга тоже не помешает)--> по ссылкам генерируется страница с флэшкой и туда передается айди уровня. При серфинге уровней страница обновляется, происходит ротация рекламы, значит больше накликают рекламы.
0
Теоретически да.
Практически же, мы говорим в любом случае о небольших процентах от общего количества просмотров игры :-)
Так что я бы ориентировался скорее на то, как это проще сделать.
И надо еще подумать, станет ли пользователь кликать куда-то на сайте или просто закроет окно, а внутри флешки может и еще поиграет, если это там будет просто сделано: например на экране выбора уровней кнопка More levels, а за ней сразу же список самых популярных и новых и примерно в том же виде, что и остальной интерфейс. Если человек игру прошел и хочет еще, то ему в таком варианте проще дальше играть, чем оказываться на другой странице со ссылками. А рекламу можно и внутри флешки показывать.
0
Супер! Спасибо! Побольше бы таких постов.
  • Regul
  • Regul
0
Тоже плюсану, сам пока сохранение уровней делать не собираюсь, но с в сторону GAE тоже посматриваю. делал тесты с PyAMF на GAE под Убунтой все довольно просто и реально оказалось не смотря на моё почти нулевое знание Питона. а тут еще почитал кода на нем, хорошо, спасиб.
А навигацию и я бы стал делать только внутри swf. ничего нереального в этом что-то не видится. JS более-менее знаю, но уверенности в нем как-то меньше, особенно на чужих сайтах. как говорил Винни-Пух: «за слонопотамов ручаться нельзя» :)
+1
Еще хотелось бы обсудить целесообразность пользовательских уровней. Плюс — можно сделать пользовательский-левелпак, если игра пойдет конечно. Минус — нужно мотивировать пользователя (конкурс-призы или рейтинговая система-ЧСВ), минус — требуется модерация, иначе все будет забито пустыми уровнями с названием: akjdshflsasd. Я думаю пользовательские уровни надо делать со второй части при условии — успешности первой.
+1
Я это вижу так: рейтинговая система: прямо в игре и по окончании уровня делаем кнопки рейтинга, достаточно двух: хорошо/плохо. Кроме того, отслеживаем соотношение игр/выигрышей, чтобы отслеживать нерешаемые уровни, можно еще время игры отслеживать. Далее в навигации по уровням два списка: новые и топовые за неделю например. Причем можно это даже без авторизации делать, анонимно всё.
Конечно надо еще какую-то автоматическую премодерацию: для моей игры не пускать уровни где нет вагонов, где не хватает вагонов для решения уровня, где нет ни одной метки, можно еще связность проверять, т.е. может ли теоретически доехать вагон до своей цели.
0
Я это вижу так: рейтинговая система: прямо в игре и по окончании уровня делаем кнопки рейтинга, достаточно двух: хорошо/плохо. Кроме того, отслеживаем соотношение игр/выигрышей, чтобы отслеживать нерешаемые уровни, можно еще время игры отслеживать. Далее в навигации по уровням два списка: новые и топовые за неделю например. Причем можно это даже без авторизации делать, анонимно всё.
Конечно надо еще какую-то автоматическую премодерацию: для моей игры не пускать уровни где нет вагонов, где не хватает вагонов для решения уровня, где нет ни одной метки, можно еще связность проверять, т.е. может ли теоретически доехать вагон до своей цели.
А модерация — не надо мне такого, спасибо, я чем-то поинтереснее займусь.
0
ОК. По результатам внедрения и выхлопам от этого надо через полгода эту тему поднять. Кстати, ананимусный рейтинг теряет свою пользу из-за легкости фальсификации.
0
спасибо! каждые несколько игр встаёт вопрос сохранения юзер контента, положил в копилку!
0
Отличная статья, не знаю только где +1 ставить :_(
+1
Снизу под постом стрелочка вверх. А у комментария справа. Когда наводишь мышку оно зеленеет.
0
Точно! получилось :)
0
+1 Действительно полезно!
  • flazm
  • flazm
0
пятибальный пост ))
Только зарегистрированные и авторизованные пользователи могут оставлять комментарии.