[{"data":1,"prerenderedAt":1009},["ShallowReactive",2],{"page:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword":3,"translations:\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword":990,"series:ru:halfword":999},{"id":4,"title":5,"author":6,"body":7,"date":974,"description":975,"draft":976,"extension":977,"image":978,"listed":979,"meta":980,"navigation":979,"part":981,"path":982,"seo":983,"series":984,"stem":985,"tags":986,"theme":984,"toc":979,"__hash__":989},"content_ru\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword.md","Я сделал игру для компании. У всех одно и то же слово. Почти.","slnt_opp",{"type":8,"value":9,"toc":963},"minimark",[10,32,35,40,46,107,118,122,125,129,132,151,154,165,168,241,244,249,252,259,262,284,287,294,301,304,308,330,333,426,441,448,452,459,466,610,613,623,771,774,781,784,788,791,797,815,819,826,830,837,864,867,871,876,883,890,893,897,906,910,913,919,922,933,937,940,956,959],[11,12,13,14,18,19,22,23,27,28,31],"p",{},"Мы с друзьями играем в Undercover, это игра в слова. Если ты не играл: почти все за столом получают одно и то же тайное слово, скажем, ",[15,16,17],"em",{},"кофе",". Несколько игроков получают почти такое же, ",[15,20,21],{},"чай",", и никто не говорит им, что они здесь белые вороны: это ",[24,25,26],"strong",{},"Undercover",". А один игрок, ",[24,29,30],{},"Mr. White",", не получает слова вовсе. Все по очереди описывают своё слово, не называя его, а потом стол голосованием кого-нибудь выгоняет. Скажешь слишком расплывчато — выглядишь подозрительно. Слишком точно — и Mr. White узнаёт слово.",[11,33,34],{},"Играли мы в другом приложении. А мне хотелось игру покрасивее, без рекламы и с парами слов, которые генерируются, а не берутся из готового списка. Первые два пункта в Halfword уже есть. Генерация пар ещё впереди.",[36,37,39],"h2",{"id":38},"что-внутри","Что внутри",[11,41,42,45],{},[24,43,44],{},"Halfword"," — это Undercover на одном телефоне, который передают по кругу:",[47,48,49,56,62,68,74,80,86,92,98,104],"ul",{},[50,51,52,55],"li",{},[24,53,54],{},"От 4 до 20 игроков, один телефон,"," iPhone или Android. Без аккаунта, без интернета, без рекламы и без отслеживания. Имена, игры и очки остаются на телефоне.",[50,57,58,61],{},[24,59,60],{},"Раздача:"," каждый удерживает кнопку, чтобы открыть свою карточку, читает её в одиночку и прячет, прежде чем передать телефон дальше. Карточку можно открыть ещё раз, но весь стол увидит, что её открывали.",[50,63,64,67],{},[24,65,66],{},"По-настоящему скрытые роли."," Мирные жители и Undercover получают одну и ту же карточку, только слово на ней другое, так что никому не говорят, кто из них кто. Свою роль знает только Mr. White, а роль игрока стол узнаёт, когда тот выбывает. У пойманного Mr. White есть одна попытка угадать слово мирных, и верная догадка крадёт победу.",[50,69,70,73],{},[24,71,72],{},"Три способа голосовать,"," и переключаться между ними можно в любой момент, даже посреди голосования: «Тайно» — настоящее тайное голосование, пока телефон идёт по кругу; «Открыто» — поднятые руки, которые ведущий отмечает на экране; или «Только итог».",[50,75,76,79],{},[24,77,78],{},"Восемь особых ролей",", которые можно подмешать: Богиня правосудия, Влюблённые, Мститель, Дуэлянты, Призрак, Mr. Meme, Бумеранг и Шут.",[50,81,82,85],{},[24,83,84],{},"Правила стола:"," играть с Mr. White или без него и решить, на чьей стороне Undercover: среди чужаков вместе с ним или на стороне стола.",[50,87,88,91],{},[24,89,90],{},"Очки на весь вечер"," в таблице лидеров. О них ниже.",[50,93,94,97],{},[24,95,96],{},"Шесть языков:"," английский, немецкий, испанский, русский, белорусский и украинский, и для каждого по 150 пар слов, написанных на этом языке, а не переведённых. Язык приложения и язык слов выбираются отдельно, так что смешанная компания может читать интерфейс на одном языке, а играть на другом.",[50,99,100,103],{},[24,101,102],{},"Сначала форма, потом цвет:"," круг у мирных, наклонённый треугольник у Undercover, пустой квадрат у Mr. White, так что цвет никогда не бывает единственной подсказкой.",[50,105,106],{},"Игру можно прервать на середине и продолжить позже ровно с того же места.",[11,108,109,110,117],{},"Игра уже вышла на iPhone и Android; ссылки на оба стора есть на ",[111,112,116],"a",{"href":113,"rel":114},"https:\u002F\u002Fhalfword.slnt-opp.xyz",[115],"nofollow","halfword.slnt-opp.xyz",".",[119,120],"gallery",{":images":121},"[{\"src\":\"\u002Fimages\u002Fhalfword\u002Fhome.jpg\",\"alt\":\"Сегодня за столом: кто лидирует, и следующая игра в одно касание\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Freveal-coffee.jpg\",\"alt\":\"Карточка Чена: кофе\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Freveal-tea.jpg\",\"alt\":\"У Дарио та же самая карточка, только на ней чай\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Freveal-mr-white.jpg\",\"alt\":\"Грета — Mr. White, и знает об этом только она\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fvoting.jpg\",\"alt\":\"Три способа голосовать; здесь открытое голосование поднятием рук\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fcascade.jpg\",\"alt\":\"Особые роли: Чен и Фатима были Влюблёнными, поэтому выбывают оба\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Ftable-wins.jpg\",\"alt\":\"Стол побеждает, и каждый, кто ещё в игре, получает долю\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fleaderboard.jpg\",\"alt\":\"Очки копятся весь вечер\"}]",[11,123,124],{},"А теперь самое интересное: то, над чем пришлось больше всего поломать голову.",[36,126,128],{"id":127},"очки-по-скользящей-шкале","Очки по скользящей шкале",[11,130,131],{},"Очки копятся за весь вечер, и в этом вся сложность. Мне хотелось, чтобы победа Mr. White ощущалась как победа, но не превращала этого игрока в лидера, которого до конца вечера уже никто не догонит.",[11,133,134,135,138,139,142,143,146,147,150],{},"В системе очков, к которой я пришёл, два главных слова. Мирные, то есть ",[24,136,137],{},"стол",", играют на ",[24,140,141],{},"долю",". Другая сторона, ",[24,144,145],{},"чужаки"," (по умолчанию Mr. White и Undercover), играет на ",[24,148,149],{},"ставку",". Обе зависят от того, скольких своих игроков потерял стол, а потери считаются шагами, которые растут вместе с размером стола.",[11,152,153],{},"Вот суть, ужатая из докстринга скрипта, который подбирал числа:",[155,156,161],"pre",{"className":157,"code":159,"language":160},[158],"language-text","step  = (n + 2) \u002F\u002F 6       1 at 4-9 players, 2 at 10-15, 3 at 16-20\nlost  = table seats out when the game ends, by any cause\nk     = lost \u002F\u002F step\nshare = max(1, 6 - k \u002F\u002F 2)   table win: to each table seat still in\nstake = 9 + min(2, k)        a right guess, or the hunted outlasting the table\n","text",[162,163,159],"code",{"__ignoreMap":164},"",[11,166,167],{},"От 4 до 9 игроков это выглядит так:",[169,170,171,199],"table",{},[172,173,174],"thead",{},[175,176,177,181,184,187,190,193,196],"tr",{},[178,179,180],"th",{},"Стол потерял игроков",[178,182,183],{},"0",[178,185,186],{},"1",[178,188,189],{},"2",[178,191,192],{},"3",[178,194,195],{},"4",[178,197,198],{},"5",[200,201,202,221],"tbody",{},[175,203,204,208,211,213,215,217,219],{},[205,206,207],"td",{},"Доля каждому игроку стола, кто ещё в игре",[205,209,210],{},"6",[205,212,210],{},[205,214,198],{},[205,216,198],{},[205,218,195],{},[205,220,195],{},[175,222,223,226,229,232,235,237,239],{},[205,224,225],{},"Ставка каждому чужаку, которому платят",[205,227,228],{},"9",[205,230,231],{},"10",[205,233,234],{},"11",[205,236,234],{},[205,238,234],{},[205,240,234],{},[11,242,243],{},"Так что стол, который по дороге потерял двоих своих, получает по 5 вместо 6, а Mr. White, который продержался столько же и потом назвал слово, забирает 11. Очки никогда не делятся, а выбывший игрок не получает ни доли, ни ставки, даже если его сторона победила (исключение — догадка Mr. White: когда он угадывает, он уже вне игры; Дуэлянты и Шут добавляют свои очки сверху).",[245,246],"halfword-art",{":items":247,":narrow":189,"caption":248},"[{\"src\":\"\u002Fimages\u002Fhalfword\u002Fillo_win_civilians.png\",\"w\":160,\"h\":96,\"alt\":\"Пять голубых кругов под вспышкой света\",\"label\":\"Стол побеждает: доля\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fillo_win_hunted.png\",\"w\":160,\"h\":96,\"alt\":\"Довольная собой Половинка между огромным розовым треугольником и квадратом Mr. White\",\"label\":\"Чужаки продержались дольше: ставка\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fillo_win_mrwhite.png\",\"w\":160,\"h\":96,\"alt\":\"Квадрат Mr. White под янтарным облачком реплики\",\"label\":\"Mr. White называет слово: ставка\"}]","Три концовки, как их рисует экран результатов",[11,250,251],{},"Числа не взяты с потолка. Скрипт-симуляция на Python, только стандартная библиотека, проигрывает игру как точную марковскую цепь по состояниям голосования. Ничья случается в одном раунде из десяти, голосование выбирает Mr. White, пока он в игре, чаще, чем кого-либо ещё, неудачная подсказка делает Undercover примерно вдвое подозрительнее мирного жителя, а пойманный Mr. White с каждым раундом угадывает чуть чаще. Каждое из этих допущений перебирается по диапазону (243 комбинации), потому что весь смысл в том, чтобы ответ не зависел от моих догадок о том, как люди голосуют.",[11,253,254,255,258],{},"Потом он играет ",[24,256,257],{},"5 000 вечеров по девять игр"," по каждой схеме, включая схему из приложения, в котором мы играли раньше (10 очков Mr. White, если он победил, по 2 каждому, кто дожил до конца, в каждой игре, и 3 или 5 выжившим, когда они победили), и задаёт вопрос, который важен для целого вечера: если кто-то в одиночку выиграл первую игру за чужаков, как часто его так никто и не догоняет до конца вечера?",[11,260,261],{},"При шести игроках:",[47,263,264,271,278],{},[50,265,266,267,270],{},"старое приложение: ",[24,268,269],{},"18%"," вечеров;",[50,272,273,274,277],{},"первый план (доля падает на единицу с каждым шагом, ставка от 8 до 12): ",[24,275,276],{},"20%",", хуже старого приложения;",[50,279,280,281,117],{},"схема выше: ",[24,282,283],{},"17%",[11,285,286],{},"Выглядит скромным улучшением, пока не увидишь обратную сторону. В той же модели место Mr. White стоит почти вдвое больше места мирного жителя (в 1,92 раза больше очков за игру), а одна победа чужаков стоит 4,8 средней игры мирного. Так что победа за Mr. White заметно вознаграждается, и при шести игроках она решает исход вечера не чаще, чем в старом приложении.",[11,288,289,290,293],{},"Ещё скрипт обходит каждую достижимую позицию и ищет того, кому ",[24,291,292],{},"выгодно выгнать голосованием своего же союзника",". С итоговыми числами таких нет, кроме Дуэлянтов, которые для этого и придуманы, и редких крайних случаев при самом большом числе игроков. Эта проверка похоронила несколько красивых на слух идей. Например, если делить ставку между победителями, Mr. White выигрывает от выбывания своего же Undercover: в 5 950 из 6 071 проверенной позиции. Старое «по 2 очка всем живым» отправилось туда же.",[11,295,296,297,300],{},"И из всего этого родилось одно правило, которое важно совсем в другом месте: ",[24,298,299],{},"очки никогда не зависят от бюллетеней."," В ранней версии правил давалось очко за голос против того, кого надо, а для этого нужно знать, кто против кого голосовал. Без этого тайное голосование может быть по-настоящему тайным: кто против кого голосовал, остаётся в движке и в журнале игры на телефоне, без которого не воспроизвести игру заново, и ни один экран ни в какой момент не может это показать.",[11,302,303],{},"И переспросить дёшево: таблица очков считается меньше чем за секунду, всё исследование на выборках поменьше — примерно за две минуты, а полный прогон — за четыре-пять.",[36,305,307],{"id":306},"одна-кодовая-база-два-языка","Одна кодовая база, два языка",[11,309,310,311,316,317,321,322,325,326,329],{},"Halfword написан один раз, на Swift и SwiftUI. Версия для Android получается через ",[111,312,315],{"href":313,"rel":314},"https:\u002F\u002Fskip.dev",[115],"Skip",", но не так, как будет у ",[111,318,320],{"href":319},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Ffare_enough","Fare Enough",". Fare Enough использует режим Fuse, который компилирует Swift под Android нативно. Halfword использует ",[24,323,324],{},"Skip Lite",": Swift ",[15,327,328],{},"переводится"," в исходники на Kotlin, и Android собирает уже их. Плата за это: можно использовать только тот Swift, который понимает Skip, а кое-что из него, став Kotlin, значит уже другое.",[11,331,332],{},"Поэтому тесты каждого модуля гоняются дважды, в двух колонках: как Swift на Mac и как Kotlin на JVM, потому что Skip транспилирует и тесты. Вот немного из того, что поймала колонка Android:",[47,334,335,362,387,404,414],{},[50,336,337,344,345,347,348,350,351,354,355,358,359,117],{},[24,338,339,340,343],{},"На Android ",[162,341,342],{},"Int"," 32-битный."," ",[162,346,342],{}," из Swift становится ",[162,349,342],{}," в Kotlin, так что ",[162,352,353],{},"Int.max"," там 2 147 483 647, и при переполнении программа не падает, как в Swift, а число молча становится отрицательным. Один и тот же баг с переполнением на одной платформе роняет приложение, а на другой даёт неправильный ответ. Всё, что может вырасти больше, хранится в ",[162,356,357],{},"Int64",", а намеренное переполнение пишется как ",[162,360,361],{},"&+",[50,363,364,367,368,371,372,375,376,379,380,383,384,386],{},[24,365,366],{},"Беззнаковые литералы не выживают."," При ",[162,369,370],{},"a: UInt64"," выражение ",[162,373,374],{},"a &* 3"," в Kotlin не компилируется, а ",[162,377,378],{},"a &* UInt64(3)"," компилируется. ",[162,381,382],{},"UInt64(bitPattern:)"," не компилируется вообще. Поэтому генератор случайных чисел в игре — SplitMix64 на знаковом ",[162,385,357],{},", а его константы записаны как отрицательные битовые паттерны.",[50,388,389,344,392,395,396,399,400,403],{},[24,390,391],{},"Сортировка должна быть строгой.",[162,393,394],{},"sorted { $0 \u003C= $1 }"," в Swift работает. На Android TimSort из Java бросает ",[15,397,398],{},"«Comparison method violates its general contract!»",". Поэтому правило такое: строгое ",[162,401,402],{},"\u003C",", а равенство разрешается по номеру места.",[50,405,406,409,410,413],{},[24,407,408],{},"Строки равны по-разному."," В Swift предкомпонованная é равна e с комбинирующим акцентом после неё, а в Kotlin нет, и ",[162,411,412],{},"\"👍\".count"," на iPhone равно 1, а на Android 2. Это настоящая проблема, когда Mr. White вводит свою догадку. Поэтому каждая догадка проходит через таблицу нормализации: сначала нижний регистр, потом каждый символ заменяется (ä на a, ß на ss, комбинирующий акцент на ничего), так что буквы любого набора слов нормализуются одинаково на обеих платформах. «Kaese» тоже засчитывается за «Käse», а в русском «ё» засчитывается за «е».",[50,415,416,344,422,425],{},[24,417,339,418,421],{},[162,419,420],{},"rounded()"," округляет половину к чётному.",[162,423,424],{},"(10.5).rounded()"," на iOS даёт 11, а на Android 10. Это важно для пиксель-арта, о нём ниже.",[11,427,428,429,432,433,436,437,440],{},"Мой любимый пример: ",[162,430,431],{},"#expect(a == 2 && b == 1)"," транспилируется в ",[162,434,435],{},"expectEqual(a == 2 && b, 1)",". Последнее ",[162,438,439],{},"=="," отрывается от всего выражения, так что при подходящих типах это компилируется и проверяет не то. Каждое такое правило записано вместе с тем, как его проверили, и их десятки.",[11,442,443,444,117],{},"Android добирался кружным путём. Первая версия движка, наборов слов и сохранения прошла все тесты в обеих колонках. Потом у моего Mac кончилась память на параллельных сборках, и я поставил Android на паузу, пока выходила версия для iPhone. Когда Android вернулся, тесты каждого модуля прошли в обеих колонках. Теперь Halfword есть в Google Play. А как экраны на Android проверяли без эмулятора — это ",[111,445,447],{"href":446},"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword_android_rendering","отдельный пост",[36,449,451],{"id":450},"игра-которую-можно-воспроизвести-заново","Игра, которую можно воспроизвести заново",[11,453,454,455,458],{},"Правила живут в модуле ",[162,456,457],{},"GameCore",", и это самый строгий код в приложении: он не импортирует ничего, кроме стандартной библиотеки Swift. Никакого Foundation, никаких часов, никакого ввода-вывода.",[11,460,461,462,465],{},"Игра — это редьюсер. Любой ввод — это действие, и ",[162,463,464],{},"apply"," — единственный способ что-либо изменить:",[155,467,471],{"className":468,"code":469,"language":470,"meta":164,"style":164},"language-swift shiki shiki-themes monokai","var game = try Game(seats: seats, config: config, pair: pair, rng: rng)\n_ = try game.apply(.startGame)        \u002F\u002F deals the cards\n\u002F\u002F … the reveals, the clues, the vote is called …\nlet events = try game.apply(.castBallot(voter: 2, target: 5))\n\u002F\u002F Screens draw game.snapshot: no words, no living player's role,\n\u002F\u002F never a secret ballot's target.\n","swift",[162,472,473,523,545,551,598,604],{"__ignoreMap":164},[474,475,478,482,486,489,492,496,499,502,505,508,511,514,517,520],"span",{"class":476,"line":477},"line",1,[474,479,481],{"class":480},"s8I7P","var",[474,483,485],{"class":484},"sCdxs"," game ",[474,487,488],{"class":480},"=",[474,490,491],{"class":480}," try",[474,493,495],{"class":494},"sYf5A"," Game",[474,497,498],{"class":484},"(",[474,500,501],{"class":494},"seats",[474,503,504],{"class":484},": seats, ",[474,506,507],{"class":494},"config",[474,509,510],{"class":484},": config, ",[474,512,513],{"class":494},"pair",[474,515,516],{"class":484},": pair, ",[474,518,519],{"class":494},"rng",[474,521,522],{"class":484},": rng)\n",[474,524,526,529,531,533,536,538,541],{"class":476,"line":525},2,[474,527,528],{"class":484},"_ ",[474,530,488],{"class":480},[474,532,491],{"class":480},[474,534,535],{"class":484}," game.",[474,537,464],{"class":494},[474,539,540],{"class":484},"(.startGame)        ",[474,542,544],{"class":543},"snpHw","\u002F\u002F deals the cards\n",[474,546,548],{"class":476,"line":547},3,[474,549,550],{"class":543},"\u002F\u002F … the reveals, the clues, the vote is called …\n",[474,552,554,557,560,562,564,566,568,571,574,576,579,582,585,588,591,593,595],{"class":476,"line":553},4,[474,555,556],{"class":480},"let",[474,558,559],{"class":484}," events ",[474,561,488],{"class":480},[474,563,491],{"class":480},[474,565,535],{"class":484},[474,567,464],{"class":494},[474,569,570],{"class":484},"(.",[474,572,573],{"class":494},"castBallot",[474,575,498],{"class":484},[474,577,578],{"class":494},"voter",[474,580,581],{"class":484},": ",[474,583,189],{"class":584},"s7s5_",[474,586,587],{"class":484},", ",[474,589,590],{"class":494},"target",[474,592,581],{"class":484},[474,594,198],{"class":584},[474,596,597],{"class":484},"))\n",[474,599,601],{"class":476,"line":600},5,[474,602,603],{"class":543},"\u002F\u002F Screens draw game.snapshot: no words, no living player's role,\n",[474,605,607],{"class":476,"line":606},6,[474,608,609],{"class":543},"\u002F\u002F never a secret ballot's target.\n",[11,611,612],{},"Отклонённое действие бросает ошибку и ничего не меняет, потому что выполняется на копии. В снимке, по которому рисуют экраны, до конца игры нет никаких секретов, так что экран не может выдать роль даже по ошибке. Один тест меняет местами два тайных голоса и проверяет, что ничего видимого не изменилось.",[11,614,615,616,619,620,622],{},"Всё случайное (кто Undercover, кому какая особая роль, кто говорит первым, кто в этом раунде объясняет жестами) тянется из ",[24,617,618],{},"одного"," генератора с сидом, в заранее записанном порядке. Это SplitMix64, на ",[162,621,357],{}," из-за правил Kotlin выше:",[155,624,626],{"className":468,"code":625,"language":470,"meta":164,"style":164},"public mutating func next() -> Int64 {\n    state = state &+ SeededRNG.gamma\n    var z = state\n    z = (z ^ SeededRNG.shiftRightLogical(z, 30)) &* SeededRNG.mix1\n    z = (z ^ SeededRNG.shiftRightLogical(z, 27)) &* SeededRNG.mix2\n    return z ^ SeededRNG.shiftRightLogical(z, 31)\n}\n",[162,627,628,656,671,684,718,744,765],{"__ignoreMap":164},[474,629,630,633,636,640,644,647,650,653],{"class":476,"line":477},[474,631,632],{"class":480},"public",[474,634,635],{"class":480}," mutating",[474,637,639],{"class":638},"sOx1s"," func",[474,641,643],{"class":642},"sHkqI"," next",[474,645,646],{"class":484},"() ",[474,648,649],{"class":480},"->",[474,651,652],{"class":638}," Int64",[474,654,655],{"class":484}," {\n",[474,657,658,661,663,666,668],{"class":476,"line":525},[474,659,660],{"class":484},"    state ",[474,662,488],{"class":480},[474,664,665],{"class":484}," state ",[474,667,361],{"class":480},[474,669,670],{"class":484}," SeededRNG.gamma\n",[474,672,673,676,679,681],{"class":476,"line":547},[474,674,675],{"class":480},"    var",[474,677,678],{"class":484}," z ",[474,680,488],{"class":480},[474,682,683],{"class":484}," state\n",[474,685,686,689,691,694,697,700,703,706,709,712,715],{"class":476,"line":553},[474,687,688],{"class":484},"    z ",[474,690,488],{"class":480},[474,692,693],{"class":484}," (z ",[474,695,696],{"class":480},"^",[474,698,699],{"class":484}," SeededRNG.",[474,701,702],{"class":494},"shiftRightLogical",[474,704,705],{"class":484},"(z, ",[474,707,708],{"class":584},"30",[474,710,711],{"class":484},")) ",[474,713,714],{"class":480},"&*",[474,716,717],{"class":484}," SeededRNG.mix1\n",[474,719,720,722,724,726,728,730,732,734,737,739,741],{"class":476,"line":600},[474,721,688],{"class":484},[474,723,488],{"class":480},[474,725,693],{"class":484},[474,727,696],{"class":480},[474,729,699],{"class":484},[474,731,702],{"class":494},[474,733,705],{"class":484},[474,735,736],{"class":584},"27",[474,738,711],{"class":484},[474,740,714],{"class":480},[474,742,743],{"class":484}," SeededRNG.mix2\n",[474,745,746,749,751,753,755,757,759,762],{"class":476,"line":606},[474,747,748],{"class":480},"    return",[474,750,678],{"class":484},[474,752,696],{"class":480},[474,754,699],{"class":484},[474,756,702],{"class":494},[474,758,705],{"class":484},[474,760,761],{"class":584},"31",[474,763,764],{"class":484},")\n",[474,766,768],{"class":476,"line":767},7,[474,769,770],{"class":484},"}\n",[11,772,773],{},"Его выход на обеих платформах совпадает с опубликованными эталонными значениями Виньи. А поскольку словари в Swift обходятся в случайном порядке, а в Kotlin — в порядке вставки, ни одно решение не принимается циклом по словарю или множеству: места обходятся по номерам.",[11,775,776,777,780],{},"Польза в том, что ",[24,778,779],{},"сохранённая игра — это просто её сид, её настройки и список действий."," Приложение сохраняет игру после каждого действия. Когда игра сохраняется впервые и ещё раз, когда она закончилась, хранилище воспроизводит её ровно из того, что собирается записать, и отказывается сохранять игру, которая не вернулась точно такой же. Так что если кому-то позвонили или телефон сел посреди тайного голосования, игра откроется ровно там, где была.",[11,782,783],{},"Тесты на это сильно опираются. Каждую из тысячи случайных игр играют дважды и воспроизводят из сохранённых действий, и все три прогона должны совпасть в каждом событии. Сотня из них сворачивается в одно число, зафиксированное в тесте, которое должны выдать и iOS, и Android. А один тест играет целый вечер из четырёх игр через модель приложения, так же, как это делают экраны, и по дороге шесть раз убивает и перезапускает приложение: посреди переигровки после ничьей, на середине тайного голосования, когда Mr. White вот-вот введёт свою догадку. И каждый раз всё должно вернуться точно как было.",[36,785,787],{"id":786},"пиксели-без-файлов-картинок","Пиксели без файлов картинок",[11,789,790],{},"В Halfword всего шестнадцать цветов. Любая картинка в нём начинается как текстовый файл, один символ на пиксель. Вот глиф Undercover размером 16 на 16 (заголовок сокращён):",[155,792,795],{"className":793,"code":794,"language":160},[158],"name: role_undercover_16\nsize: 16x16\noutline: full\n---\nframe 0\n................\n..........K.....\n.........KRK....\n........KRRK....\n.......KRRRrK...\n......KRRRRrK...\n.....KRRRRRrK...\n....KRRRRRRrK...\n...KRRRRRRRrrK..\n..KRRRRRRRRrrK..\n.KRRRRRRRRRrrK..\n..KKRRRRRRRrrK..\n....KKKKRRRRrrK.\n........KKKKrrK.\n............KK..\n................\n",[162,796,794],{"__ignoreMap":164},[11,798,799,802,803,806,807,810,811,814],{},[162,800,801],{},"K"," — контур, ",[162,804,805],{},"R"," — розовый, ",[162,808,809],{},"r"," — его тёмный оттенок. Маленькая утилита ",[162,812,813],{},"pixelkit.py"," (снова Python на одной стандартной библиотеке) проверяет каждый файл по правилам рисования и экспортирует PNG: размеры кратны 8, только цвета палитры, маскот нарисован своими четырьмя цветами и анимирован ровно с частотой 8 кадров в секунду, и никакого цвета роли на карточке, которую видит каждый игрок.",[245,816],{":items":817,":narrow":210,"caption":818},"[{\"src\":\"\u002Fimages\u002Fhalfword\u002Frole_undercover_16.png\",\"w\":16,\"h\":16,\"alt\":\"Глиф Undercover: наклонённый розовый треугольник\"}]","Тот же глиф, каждый пиксель нарисован 6 на 6",[11,820,821,822,825],{},"Маскот — ",[24,823,824],{},"Половинка",": маленький спрайт, у которого правая сторона так до конца и не появилась, пиксели редеют в дизеринг, а потом сходят на нет. У неё шесть настроений. Вот четыре, которые крутятся по кругу:",[245,827],{":items":828,":narrow":189,"caption":829,":wide":192},"[{\"src\":\"\u002Fimages\u002Fhalfword\u002Fhalf_idle_sheet.png\",\"w\":32,\"h\":32,\"frames\":4,\"alt\":\"Половинка ждёт\",\"label\":\"ждёт\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fhalf_peek_sheet.png\",\"w\":32,\"h\":32,\"frames\":2,\"alt\":\"Половинка подглядывает\",\"label\":\"подглядывает\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fhalf_suspicious_sheet.png\",\"w\":32,\"h\":32,\"frames\":4,\"alt\":\"Половинка что-то подозревает\",\"label\":\"подозревает\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fhalf_smug_sheet.png\",\"w\":32,\"h\":32,\"frames\":4,\"alt\":\"Половинка довольна собой\",\"label\":\"ухмыляется\"}]","Половинка ждёт, подглядывает, подозревает и ухмыляется, 8 кадров в секунду",[11,831,832,833,836],{},"Правило показа строгое: ",[24,834,835],{},"только целые масштабы",", 2×, 3×, 4× или 6×, один масштаб на экран, никакого сглаживания, никаких поворотов. На iPhone это просто, потому что пункт — это ровно 2 или 3 пикселя. На Android нет. У обычного телефона плотность 2,625, так что «2× в dp» даёт одни пиксели шириной 5 физических пикселей, а другие — 6, а пиксель-арт разваливается, когда его пиксели разного размера.",[11,838,839,840,843,844,847,848,851,852,855,856,859,860,863],{},"Хуже того, ",[162,841,842],{},"Image"," из SkipUI вообще не умеет рисовать пиксель-арт на Android: ",[162,845,846],{},".interpolation(.none)"," там ничего не делает, и каждый битмап проходит билинейную фильтрацию. Поэтому ни один спрайт не попадает в приложение картинкой. В приложение попадают те же строки ключей палитры, а каждый спрайт рисуется фигурами SwiftUI: один ",[162,849,850],{},"Path"," из прямоугольников в целый пиксель на каждый цвет. Тогда на Android исходный пиксель — это ровно ",[162,853,854],{},"n × n"," физических пикселей, где ",[162,857,858],{},"n = round(scale × density)",". Этот ",[162,861,862],{},"round"," прописан явно как «половина округляется от нуля», потому что, как мы видели выше, Kotlin округлил бы 10,5 вниз, до 10.",[11,865,866],{},"Правила зашиты и в карточки. Мирные и Undercover не должны знать, кто из них кто, поэтому карточка у них одна на всех, без цвета роли. Отличается только карточка Mr. White, пустой квадрат, и она такая же тёмная, как остальные, чтобы её свечение не выдало его через весь стол:",[245,868],{":items":869,":narrow":189,"caption":870},"[{\"src\":\"\u002Fimages\u002Fhalfword\u002Fcard_back.png\",\"w\":96,\"h\":64,\"alt\":\"Рубашка карточки: янтарное облачко реплики, которое растворяется вправо\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fcard_word.png\",\"w\":96,\"h\":64,\"alt\":\"Карточка со словом: целое янтарное облачко реплики\"},{\"src\":\"\u002Fimages\u002Fhalfword\u002Fcard_mrwhite.png\",\"w\":96,\"h\":64,\"alt\":\"Карточка Mr. White: пустой квадрат\"}]","Рубашка, карточка каждого, у кого есть слово, и карточка Mr. White",[872,873,875],"h3",{"id":874},"буква-c-которая-читалась-как-скобка","Буква C, которая читалась как скобка",[11,877,878,879,882],{},"Заголовки и реплики Половинки набраны ",[24,880,881],{},"Micro 5",", пиксельным шрифтом с открытой лицензией. У него два изъяна. Заглавная C шириной в два пикселя и с прямыми углами, так что «Chen» читается как «[hen», а «Count the votes» — как «[ount the votes». G с одним срезанным углом читается как D: «Greta» превращается в «Dreta». Когда заголовки сделали вдвое крупнее, буквы стали больше, но не стали другими.",[11,884,885,886,889],{},"Поэтому в приложении пропатченный Micro 5. Скрипт хранит новые рисунки и пересобирает шрифт из исходного файла, каждый раз байт в байт: подменяет эти глифы (C и G, их варианты с диакритикой и типографские кавычки), а потом вручную пересобирает таблицы шрифта, ограничивающие прямоугольники и контрольные суммы, на одной стандартной библиотеке. Он сохраняет ",[24,887,888],{},"ширину каждого символа",", потому что приложение меряет имена в пикселях шрифта, чтобы они влезали в одну строку заголовка («Marie-Christine» — ровно 52, это предел), и более широкая C сдвинула бы эту границу. У Micro 5 нет зарезервированного имени шрифта (Reserved Font Name), так что по OFL изменённая копия может сохранить название.",[11,891,892],{},"G починена: «Greta» читается как Greta. C стала лучше, но не починена: в полный размер она всё ещё немного похожа на круглую скобку, «(hen». Третий пиксель вылечил бы её, но вытолкнул бы «Marie-Christine» на пиксель за предел.",[245,894],{":items":895,":narrow":192,"caption":896,":wide":195},"[{\"src\":\"\u002Fimages\u002Fhalfword\u002Fmicro5-c-and-g.png\",\"w\":59,\"h\":78,\"alt\":\"«Chen», «Count the votes» и «Greta», каждое сначала исходным Micro 5, потом пропатченным шрифтом\"}]","Исходный Micro 5, затем пропатченный; чёрточки отмечают перерисованные буквы",[11,898,899,900,905],{},"В ",[111,901,904],{"href":902,"rel":903},"https:\u002F\u002Fslnt-opp.xyz\u002Fposts\u002Fcode_n_shit\u002Fhalfword",[115],"английской версии"," этого поста заголовки набраны как раз этим пропатченным шрифтом. Кириллицы в Micro 5 нет, поэтому здесь они набраны системным шрифтом, как и заголовки в самом приложении, когда оно говорит по-русски.",[36,907,909],{"id":908},"сначала-макеты","Сначала макеты",[11,911,912],{},"Ещё до первой строчки кода у приложения были спецификация, визуальные гайдлайны, своя графика и дизайн-холст с нарисованными экранами. Гайдлайны задали правила, на которые этот пост уже опирался (шестнадцать цветов, целые масштабы, форма прежде цвета), и ещё два: пиксель-арт для содержимого, а нативные элементы управления для всего, на что нажимают; и каждый цвет текста должен проходить WCAG AA на своём фоне.",[11,914,915,916,918],{},"Заметки арт-дирекции появились, когда по этим правилам начали рисовать, и они поправляют правила измерениями. Гайдлайны предлагали amber-dk для янтарного текста в светлой теме; на фоне paper он даёт 3,29:1 и для основного текста не годится, поэтому цветной текст в светлой теме — green-dk или rose-dk, а янтарный там только заливка. Paper на rose — это 3,11:1, поэтому подписи на розовых кнопках тёмные. Расчёт размеров в физических пикселях, описанный выше, тоже начинался как одна из этих заметок: в собственном примере кода для Android из гайдлайнов спрайты задавались в dp. А раз круг, треугольник и квадрат принадлежат ролям, ни одна иконка не может их использовать: треугольный знак предупреждения читался бы как ",[15,917,26],{},", поэтому иконка предупреждения — просто «!», а таймер — песочные часы, а не циферблат. Где заметки и гайдлайны расходятся, побеждают заметки.",[11,920,921],{},"На холсте 44 артборда. Большинство — экраны телефона размером 390 на 844 пункта, от заставки до таблицы лидеров, некоторые ещё раз в светлой теме. Остальные задают основу: палитру с измеренным контрастом, шесть состояний маскота, иконки, рисунки ролей, иллюстрации и иконку приложения. Каждый экранный артборд рассказывает один и тот же вечер: те же восемь игроков, кофе против чая, Дарио — Undercover, Грета — Mr. White, и очки, которые складываются от артборда к артборду. Каждый экран объявляет свой пиксельный масштаб, а линтер сверяет с ним каждую картинку на экране.",[11,923,924,925,928,929,932],{},"Каждый экран, у которого есть артборд, сделан по нему. Фикстуры приложения, то есть состояния, которые оно открывает сразу при запуске для тестов и скриншотов, проигрывают тот же самый вечер, и фикстура носит имя артборда, по которому её делали, ",[162,926,927],{},"Round-Voting"," или ",[162,930,931],{},"Resolve-Cascade",", так что любой скриншот можно сверить с его рисунком. Скриншоты для сторов, в том числе те, что в этом посте, сделаны из тех же фикстур. Некоторые артборды теперь отстают от правил; пока их не перерисуют, эталон — скриншоты.",[36,934,936],{"id":935},"вот-и-всё","Вот и всё",[11,938,939],{},"Halfword говорит на шести языках, работает офлайн, есть для iPhone и Android и ни у кого не просит аккаунт.",[47,941,942,949],{},[50,943,944,945,948],{},"Скачать: ",[111,946,116],{"href":113,"rel":947},[115],", там ссылки на App Store и Google Play",[50,950,951,952],{},"Идеи, баги, слишком простая пара слов: ",[111,953,955],{"href":954},"mailto:info@slnt-opp.xyz","info@slnt-opp.xyz",[11,957,958],{},"А если за твоим столом Mr. White всё равно крадёт вечер, напиши мне. У меня для этого есть скрипт.",[960,961,962],"style",{},"html pre.shiki code .s8I7P, html code.shiki .s8I7P{--shiki-default:#F92672}html pre.shiki code .sCdxs, html code.shiki .sCdxs{--shiki-default:#F8F8F2}html pre.shiki code .sYf5A, html code.shiki .sYf5A{--shiki-default:#66D9EF}html pre.shiki code .snpHw, html code.shiki .snpHw{--shiki-default:#88846F}html pre.shiki code .s7s5_, html code.shiki .s7s5_{--shiki-default:#AE81FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sOx1s, html code.shiki .sOx1s{--shiki-default:#66D9EF;--shiki-default-font-style:italic}html pre.shiki code .sHkqI, html code.shiki .sHkqI{--shiki-default:#A6E22E}",{"title":164,"searchDepth":525,"depth":525,"links":964},[965,966,967,968,969,972,973],{"id":38,"depth":525,"text":39},{"id":127,"depth":525,"text":128},{"id":306,"depth":525,"text":307},{"id":450,"depth":525,"text":451},{"id":786,"depth":525,"text":787,"children":970},[971],{"id":874,"depth":547,"text":875},{"id":908,"depth":525,"text":909},{"id":935,"depth":525,"text":936},"2026-10-01","Halfword — это игра в слова Undercover на одном телефоне, который передают по кругу: пиксель-арт, восемь особых ролей и очки, которые копятся весь вечер. Она уже вышла на iPhone и Android. Рассказываю, чего стоило её сделать: очки, подобранные симуляцией, одна кодовая база на Swift для двух платформ, движок, который может воспроизвести любую игру заново, пиксель-арт без файлов картинок и экраны, нарисованные раньше, чем написан код.",false,"md","\u002Fimages\u002Fhalfword\u002Fcover.png",true,{},null,"\u002Fru\u002Fposts\u002Fcode_n_shit\u002Fhalfword",{"title":5,"description":975},"halfword","ru\u002Fposts\u002Fcode_n_shit\u002Fhalfword",[162,470,987,988],"ios","games","i-BhKSYzPFvI8yCxwGzVwZoOAaspQp9ZfRMJjRoAx8E",[991,995],{"locale":992,"path":993,"title":994},"en","\u002Fposts\u002Fcode_n_shit\u002Fhalfword","I made a party game. Everyone gets the same word. Almost.",{"locale":996,"path":997,"title":998},"de","\u002Fde\u002Fposts\u002Fcode_n_shit\u002Fhalfword","Ich habe ein Partyspiel gemacht. Alle bekommen dasselbe Wort. Fast alle.",{"slug":984,"title":1000,"path":1001,"chapters":1002},"Как я делал Halfword","\u002Fru\u002Fseries\u002Fhalfword",[1003,1004],{"path":982,"title":5,"description":975,"date":974,"image":978,"draft":976,"series":984,"part":981,"n":477},{"path":446,"title":1005,"description":1006,"date":1007,"image":1008,"draft":976,"series":984,"part":981,"n":525},"Я проверил свои экраны для Android без эмулятора. Все 114, на четырёх языках.","Эмулятор Android и куча Gradle-сборок положили мой Mac, поэтому экраны Halfword для Android рисуются на JVM: Robolectric запускает настоящее приложение, Roborazzi сохраняет картинки, 456 штук на четырёх языках за 2 минуты 19 секунд. Рассказываю, как это работает, что оно поймало и чего показать не может.","2026-10-02","\u002Fimages\u002Fhalfword_android\u002Fcover.jpg",1791333557264]