Как один программист Jocly подковал

в 5:59, , рубрики: Board Games, Checkers, javascript, Jocly, разработка игр, шашки, метки:

Как один программист Jocly подковал - 1— Ученик Почтальона Стэнли — пробормотал Грош
— Сирота, сэр. Очень печальная история… Хороший мальчик, если его не злить,
… если вы понимаете, что я имею в виду.
— Э… возможно. — сказал Мокрист и поспешно повернулся к Стэнли
— Итак, знаешь кое-что о булавках?…
— Нетсэр! — ответил Стэнли…
— О булавках я знаю всё!  
 
                            сэр Терри Пратчетт "Опочтарение".
 

В далёком 1998 году, Zillions of Games произвела фурор в среде любителей настольных игр, но она не была лишена недостатков. Главным её недостатком являлась закрытость. Для того, чтобы играть во что-то сверх набора из 48 игр, входящих в демо-комплект, приходилось платить деньги за активацию программы. Было невозможно запустить ZoG на чём-то кроме Windows (с некоторыми версиями этой ОС вполне могли возникнуть проблемы). Сетевой режим был, но только по локальной сети или через модем, Web не подразумевался. С этим ничего нельзя поделать, это закрытый продукт! Кроме того, в настоящее время, он практически не поддерживается. Я думаю, что многие будут рады услышать, что существует альтернатива, свободная от перечисленных выше недостатков. Знакомьтесь, это Jocly.

Разработчики Jocly вдохновлялись примером Zillions of Games, но пошли по принципиально иному пути. Во главу угла был с самого начала поставлен Web. Вы можете запустить Jocly-приложение в любом современном браузере, на любой платформе, включая мобильные! В большинстве случаев, вы сможете пользоваться современным 3D-интерфейсом, но если возникнут проблемы с совместимостью, Jocly самостоятельно переключится на 2D. Можно играть как с компьютером, так и с другими людьми, просматривать ранее сыгранные партии и даже общаться с другими игроками через видео-чат. Вот здесь можно посмотреть краткое описание возможностей продукта, а также его сравнение с Zillions of Games.

Как один программист Jocly подковал - 2Конечно же, такая огромная бочка мёда никак не могла обойтись без маленькой ложки дёгтя (хотя, это кому как). Jocly не поддерживает каких либо DSL, наподобие ZRF или GDL и разработку приходится вести на чистом и незамутнённом JavaScript. Сами разработчики признают, что это более трудоёмкий подход, но у него есть гигантский плюс — на JavaScript можно описать практически всё что угодно. Вернее можно было бы описать, если бы сама Jocly не накладывала пару ограничений. В текущей реализации, поддерживаются лишь игры двух игроков с полной информацией и без случайных событий. Эти довольно-таки суровые ограничения связаны, насколько я понимаю, с используемыми алгоритмами AI (Alpha–beta и UCT Monte Carlo). 
 

Как бы там ни было, разработчики, на мой взгляд, сделали главное — отделили модель игры от её визуального представления. И то и другое можно писать отдельно! Работая над моделью, программист может полностью отвлечься от вопросов её визуализации, а вплотную занявшись представлением, вполне способен реализовать, помимо привычного 2D (единственно-возможного в ZoG), ещё и честный 3D-интерфейс. Это сложно, но вполне реализуемо. При большом желании, можно даже разработать свой собственный дизайн фигур, нарисовав его в Blender-е.

Лучший способ понять — сделать что-то, пусть даже совсем небольшое, самому. Поскольку материал по кастомизации шахмат на wiki авторов проекта уже был, я решил посмотреть в сторону шашек. Для просмотра деталей реализации я использовал Jocly Inspector. В наличии имелись «Международные», «Английские», «Испанские», «Бразильские шашки». Всё что угодно, кроме "Русских шашек". Но если чего-то нет — надо просто это сделать!

Любое приложение Jocly можно запустить на своём компьютере (со слов разработчиков, это единственный способ запуска кастомизированных приложений). Сделать это поможет Jocly jQuery plugin. Вот здесь имеется неплохая подборка примеров, с демонстрацией его возможностей. Для начала работы, требуется всего три файла: jquery.jocly.min.js, jquery.jocly.min.css и небольшой html-файл. Если делать всё «по правильному», необходимо положить их в каталог документов любого Web-сервера (например Apache), но, как показала практика, если вы используете FireFox, достаточно просто загрузить в него наш html-файл (с другими браузерами такой фокус не сработал).

Вот что он содержит

<!DOCTYPE html>
<html>
  <head>
    <meta charset='utf-8'>
	<link rel="stylesheet" href="jquery.jocly.min.css">
	<script src="http://code.jquery.com/jquery-1.10.1.min.js"></script>
	<script src="jquery.jocly.min.js"></script>

    <title>Jocly development stub web page</title>
    <script>
    	$(document).ready(function() {
    		$("#applet").jocly({});
    		$("#applet").jocly("localPlay","custom-draughts",{ });
    		$("#applet").jocly("setFeatures",{
    			notifyEnd: false,
    			hasEndSound: false,		
    		});
    		$("#options").joclyListener("listen","viewOptions",function(message) {
    			console.log("viewOptions",message);
    			$("#options-skin").hide().children("option").remove();
    			if(message.options.skin && message.skins && message.skins.length>0) {
    				message.skins.forEach(function(skin) {
    					$("<option/>").attr("value",skin.name).text(skin.title).appendTo($("#options-skin"));
    				});
    				$("#options-skin").show().val(message.options.skin);
    			}
    			$("#options-notation").hide();
    			if(message.options.notation!==undefined)
    				$("#options-notation").show().children("input").prop("checked",message.options.notation);
    			$("#options-moves").hide();
    			if(message.options.moves!==undefined)
    				$("#options-moves").show().children("input").prop("checked",message.options.moves);
    			$("#options-autocomplete").hide();
    			if(message.options.autocomplete!==undefined)
    				$("#options-autocomplete").show().children("input").prop("checked",message.options.autocomplete);
    			$("#options-sounds").hide();
    			if(message.options.sounds!==undefined)
    				$("#options-sounds").show().children("input").prop("checked",message.options.sounds);
    			$("#options").show();
    		});
    		$("#options").on("change",function() {
    			var options={};
    			if($("#options-skin").is(":visible")) 
    				options.skin=$("#options-skin").val();
    			if($("#options-notation").is(":visible"))
    				options.notation=$("#options-notation-input").prop("checked");
    			if($("#options-moves").is(":visible"))
    				options.moves=$("#options-moves-input").prop("checked");
    			if($("#options-autocomplete").is(":visible"))
    				options.autocomplete=$("#options-autocomplete-input").prop("checked");
    			if($("#options-sounds").is(":visible"))
    				options.sounds=$("#options-sounds-input").prop("checked");
    			$("#applet").jocly("viewOptions",options);
    		});
    		var defaultLevel=0;
    		$("#mode-panel").joclyListener("listen","players",function(message) {
    			console.warn("players",message);
    			function UpdatePlayer(player,key,levels) {
        			if(player.type=="computer") {
        				var select=$("#select-level-"+key);
        				select.empty();
        				for(var i=0;i<levels.length;i++)
        					$("<option/>").attr("value",i).text(levels[i].label).appendTo(select);
        				select.val(player.level);
        				$("#level-"+key).show();
        			} else
        				$("#level-"+key).hide();        				
    			}
    			UpdatePlayer(message.players[1],'a',message.levels);
    			UpdatePlayer(message.players[-1],'b',message.levels);
    			var modeSelect=$("#mode");
    			modeSelect.show();
    			if(message.players[1].type=="self" && message.players[-1].type=="self")
    				modeSelect.val("self-self");
    			else if(message.players[1].type=="self" && message.players[-1].type=="computer")
    				modeSelect.val("self-comp");
    			else if(message.players[1].type=="computer" && message.players[-1].type=="self")
    				modeSelect.val("comp-self");
    			else if(message.players[1].type=="computer" && message.players[-1].type=="computer")
    				modeSelect.val("comp-comp");
    			else
    				modeSelect.hide();
    			message.levels.forEach(function(level,index) {
    				if(level.isDefault)
    					defaultLevel=index;
    			});
    			$("#mode-panel").show();
    		});
    		$("#mode-panel").on("change",function() {
    			console.log("changed mode",$("#mode").val(),$("#select-level-a").val(),$("#select-level-b").val());
    			var players;
    			switch($("#mode").val()) {
    			case "self-self":
    				players={"1":{type:"self"},"-1":{type:"self"}};
    				break;
    			case "self-comp":
    				players={"1":{type:"self"},"-1":{type:"computer",level:$("#select-level-b").val() || defaultLevel}};
    				break;
    			case "comp-self":
    				players={"1":{type:"computer",level:$("#select-level-a").val() || defaultLevel},"-1":{type:"self"}};
    				break;
    			case "comp-comp":
    				players={"1":{type:"computer",level:$("#select-level-a").val() || defaultLevel},
    					"-1":{type:"computer",level:$("#select-level-b").val() || defaultLevel}};
    				break;
    			}
    			$("#applet").jocly("setPlayers",players);
    		});
    		$("#restart").on("click",function() {
    			$("#applet").jocly("restartGame");    			
    		});
    		$("#takeback").on("click",function() {
    			$("#applet").jocly("takeBack");    			
    		});
        	$("#fullscreen").on("click",function() {
        		$("#applet").joclyFullscreen();
        	});
    	});
    </script>
    <style type="text/css">
* {
	box-sizing: border-box;
}
body {
}
#container {
	width: 100%;
	display: table;
	table-layout: fixed;
}
#applet {
	display: table-cell;
	width: 60%;
}
#controls {
	display: table-cell;
	width: 33%;
	vertical-align: top;
	padding: 0 .5em 0 .5em;
}
.box {
	background-color: #f0f0f0;
	border: 2px solid #e0e0e0;
	border-radius: 1em;
	padding: 1em;
}
    </style>
    
    <script type="text/jocly-model-view" data-jocly-game="draughts/custom-draughts">
    <!-- Сюда включаем описание игры -->
    </script>
  </head>
  <body>
	<div id="container">
		<div id="applet"></div>
		<div id="controls">
	       	<div id="mode-panel" style="display: none;" class="box">
	       		<h3>Controls</h3>
	       		<button id="restart">Restart game</button><br/><br/>
	       		<button id="takeback">Take back</button><br/><br/>
	       		<select id="mode">
	       			<option value="self-self">Self / Self</option>
	       			<option value="self-comp">Self / Computer</option>
	       			<option value="comp-self">Computer / Self</option>
	       			<option value="comp-comp">Computer / Computer</option>
	       		</select><br/><br/>
	       		<label id="level-a" for="select-level-a">Computer(A) level<br/>
	        		<select id="select-level-a"></select><br/><br/>
	       		</label>
	       		<label id="level-b" for="select-level-b">Computer(B) level<br/>
	        		<select id="select-level-b"></select><br/><br/>
	       		</label>
	       		<button id="fullscreen">Full screen</button><br/><br/>
	       	</div>
	       	<br/>
	       	<div id="options" style="display: none;"  class="box">
	       		<h3>Options</h3>
	       		<select id="options-skin"></select><br/><br/>
	       		<label id="options-notation" for="options-notation-input">
	       			<input id="options-notation-input" type="checkbox"/> Notation<br/>
	       		</label>
	       		<label id="options-moves" for="options-moves-input">
	       			<input id="options-moves-input" type="checkbox"/> Show possible moves<br/>
	       		</label>
	       		<label id="options-autocomplete" for="options-autocomplete-input">
	       			<input id="options-autocomplete-input" type="checkbox"/> Auto-complete moves<br/>
	       		</label>
	       		<label id="options-sounds" for="options-sounds-input">
	       			<input id="options-sounds-input" type="checkbox"/> Sounds<br/>
	       		</label>
	       	</div>
		</div>
	</div>
  </body>
</html>

Для простого запуска игры, можно было бы обойтись минимальным html-файлом, описанным в этом руководстве, но с его более полным вариантом работать будет гораздо удобнее. Теперь, необходимо включить в html-файл JSON-описание игры. Здесь есть тонкий момент. Наш вариант игры будет называться «custom-draughts» (сейчас, это имя встречается в файле дважды). Мы можем взять описание игры из текстового поля Jocly Inspector-а целиком, но если мы изменяем лишь часть файлов, это может быть излишним. Вполне достаточно описать лишь ту часть модели, в которую мы внесли изменения, остальное Jocly возьмёт со своего сайта, но для того, чтобы это работало, имя должно быть составлено следующим образом: "draughts/custom-draughts". Часть имени перед слэшем — имя, своего рода, «родительской» игры, из которой будет браться всё недостающее. Повторюсь, эта часть имени не нужна, если используется полное JSON-описание.

Здесь всё что нам понадобится
    <script type="text/jocly-model-view" data-jocly-game="draughts/custom-draughts">
    {
	"view": {
	    "js": [
	       "checkers-xd-view.js",
	       "draughts8-xd-view.js"
	    ]
	},
	"model": {
            "js": [
               "checkersbase-custom-model.js",
               "draughts-model.js"
            ],
	    "gameOptions": {
	      	"preventRepeat": true,
      		"width": 4,
      		"height": 8,
      		"initial": {
		        "a": [[0,0],[0,1],[0,2],[0,3],[1,0],[1,1],[1,2],[1,3],[2,0],[2,1],[2,2],[2,3]],
        		"b": [[7,0],[7,1],[7,2],[7,3],[6,0],[6,1],[6,2],[6,3],[5,0],[5,1],[5,2],[5,3]]
      		},
      		"variant": {
                        "compulsoryCatch": true,
                        "canStepBack": false,
                        "mustMoveForward": false,
		        "mustMoveForwardStrict": true,
                        "lastRowFreeze": false,
        		"lastRowCrown": true,
        		"captureLongestLine": true,
                        "kingCaptureShort": false,
                        "canCaptureBackward": true,
                        "longRangeKing": true,
                        "captureInstantRemove": false,
        		"lastRowFactor": 0.001
      		},
      		"uctTransposition": "state"
            }
	}
    }
    </script>

    <script type="text/jocly-resources" data-jocly-game="custom-draughts">
    {
        "checkersbase-custom-model.js": "checkersbase-custom-model.js"
    }
    </script>

В первую очередь, в глаза бросается описание размеров доски и начальной расстановки фигур (последнее имеется далеко не во всех Jocly-играх). Немного сложно привыкнуть к тому, что доска описывается как 4x8 (неиспользуемые в диагональных шашечных системах поля моделью не описываются), а все индексы размещения фигур начинаются с нуля. Далее следует список булевских настроек, достаточный (с точки зрения разработчиков) для описания любых шашечных игр. Мы его пополним. Не обязательно указывать все настройки, я составил полный список, исключительно для своего удобства. Важно описать в "text/jocly-resources" все файлы, которые мы будем отдавать со своего сервера. Файл "checkersbase-custom-model.js" — та часть модели, в которую будут вноситься изменения. Первоначально, это просто копия файла "checkersbase-model.js".

Настало время подумать о том, что мы будем менять. Чем отличаются «Русские шашки» от «Бразильских» (имеющихся в комплекте Jocly)? На самом деле, всего двумя «мелочами». «Бразильские шашки» играются по правилам «Международных» или «Польских шашек», но на доске 8x8. В них действует «правило большинства»: из двух и более вариантов взятия игрок должен выбрать тот, при котором «срубит» максимальное количество шашек противника, независимо от их качества. В «Русских шашках» опцию необходимо отключить. С этим всё просто, свойство управляется булевской настройкой "captureLongestLine".

Кстати

Интересно посмотреть, как правило большинства реализовано в шашках от Jocly. Если составной ход рассматривается как единое целое, задача становится тривиальной. В самом конце метода генерации ходов "Model.Board._GenerateMoves" имеется следующий фрагмент кода:

Выбор из списка сгенерированных ходов

...
if(aGame.g.captureLongestLine) {
	var moves0=this.mMoves;
	var moves1=[];
	var bestLength=0;
	for(var i in moves0) {
		var move=moves0[i];
		if(move.pos.length==bestLength)
			moves1.push(move);
		else if(move.pos.length>bestLength) {
			moves1=[move];
			bestLength=move.pos.length;
		}
	}
	this.mMoves=moves1;
}
...

У нас есть список ходов (в том или ином представлении) и из него необходимо выбрать лишь те ходы, которые берут максимальное количество фигур (в интерпретации Jocly — состоят из максимального числа шагов). В ZoG, с её концепцией «частичных» ходов, пришлось добавлять хардкодную опцию "maximal captures" непосредственно в приложение, чтобы реализовать аналогичный функционал.

Больше сложностей возникает с другим правилом: если шашка стала дамкой в ходе серии взятий, после превращения она продолжает «рубку» без остановки, уже по правилам дамки. В «Международных», а также «Бразильских шашках», действует другое правило: если шашка оказалась на последней линии в ходе серии взятий и может бить дальше в роли простой шашки, то она продолжает бой и не превращается! Найдём в коде то место, где происходит превращение:

Это метод ''Model.Board.ApplyMove''

Model.Board.ApplyMove = function(aGame,move) {
+	var pieceCrowned=false;
	var WIDTH=aGame.mOptions.width;
	var HEIGHT=aGame.mOptions.height;
	var pos0=move.pos[0];
	var pIndex=this.board[pos0];
	var piece=this.pieces[pIndex];
	var player=piece.s;
	piece.l=pos0;
	var toBeRemoved={};
	this.zSign=aGame.zobrist.update(this.zSign,"board",piece.s+"/"+piece.t,piece.p);
	for(var i=1;i<move.pos.length;i++) {
		var pos=move.pos[i];
		this.board[piece.p]=-1;
		piece.p=pos;
+		if (aGame.g.russianCustom==true) {
+			var r=aGame.g.Coord[pos][0];
+			if((player==JocGame.PLAYER_A && r==HEIGHT-1) || (player==JocGame.PLAYER_B && r==0)) {
+				pieceCrowned=true;
+			}
+		}
		this.board[pos]=pIndex;
		var caught=move.capt[i];
		if(caught!=null) {
			if(this.board[caught]>=0)
				toBeRemoved[this.board[caught]]=true;
			this.board[caught]=-1;
		}
		pos0=pos;
	}
	this.zSign=aGame.zobrist.update(this.zSign,"board",piece.s+"/"+piece.t,pos);
	var plp=move.capt[move.capt.length-1]
	piece.plp=plp?plp:move.pos[move.pos.length-2];
	for(var index in toBeRemoved) {
		var piece0=this.pieces[index];
		var other=(1-piece0.s)/2;
		this.pCount[other]--;
		switch(piece0.t) {
			case 0: this.spCount[other]--; break;
			case 1: this.kpCount[other]--; break;
		}
		this.zSign=aGame.zobrist.update(this.zSign,"board",piece0.s+"/"+piece0.t,piece0.p);
		this.pieces[index]=null;
	}
	if(aGame.g.lastRowCrown && this.pieces[pIndex].t==0) {
		var r=aGame.g.Coord[move.pos[move.pos.length-1]][0];
-		if((player==JocGame.PLAYER_A && r==HEIGHT-1) || (player==JocGame.PLAYER_B && r==0)) {
+		if(pieceCrowned || (player==JocGame.PLAYER_A && r==HEIGHT-1) || (player==JocGame.PLAYER_B && r==0)) {
			var piece0=this.pieces[pIndex];
			piece0.t=1;
			var self=(1-player)/2;
			this.spCount[self]--;
			this.kpCount[self]++;
			this.zSign=aGame.zobrist.update(this.zSign,"board",piece0.s+"/0",piece0.p);
			this.zSign=aGame.zobrist.update(this.zSign,"board",piece0.s+"/1",piece0.p);
		}
	}
}

Можно заметить, что внесённые изменения, а также модель доски, ходов, фигур и прочего, далеки от интуитивных. В коде выполняется много дополнительных действий (типа вычисления Zobrist Hash) и во всём этом совсем не трудно заблудиться. Это вам не ZRF! Суть изменений проста — мы запоминаем факт прохождения через последнюю горизонталь (первую для чёрных) и, если он имел место, превращаем фигуру так, как если бы в конце хода оказались на горизонтали превращения. Посмотрим, как всё работает:

Вроде бы всё правильно. Не будем обращать внимание на то, что превращение происходит по завершении хода, а не в его процессе. В рамках текущей реализации модели, превращение фигуры посреди хода — не лучшая идея (всё сломается, я проверял)! Но всё ли мы предусмотрели? Чуть-чуть изменим позицию:

Да, это то чего мы боялись. Дойдя до последней горизонтали, шашка «не знает», что дальше она имеет право «есть» как дамка! Попробуем ей объяснить. При выполнении хода, принимать решения о том, кто кого ест, уже немного поздно. Логично искать нужное место в методе генерации ходов, а именно в функции "catchPieces". В её последний параметр передаётся флаг "king", показывающий, что мы имеем дело с дамкой. Попробуем его изменить при прохождении последней горизонтали:

Я не сразу додумался до такого

function catchPieces(pos,poss,capts,dirs,king) {
	while(true) {
		var nextPoss=[];
		var nextCapts=[];
		var nextDirs=[];
		aGame.CheckersEachDirection(pos,function(pos0,dir) {
			var r;
			if(aGame.g.canCaptureBackward==false)
				r=aGame.g.Coord[pos][0];
			var dir0=aGame.Checkers2WaysDirections[dir];
+			if (aGame.g.russianCustom==true) {
+				if($this.board[pos0]>=0 && $this.pieces[$this.board[pos0]].s==-$this.mWho) {
+					var pp=aGame.g.Graph[pos0][dir];
+					if (aGame.g.Coord[pp]) {
+						var rr=aGame.g.Coord[pp][0];
+						var HEIGHT=aGame.mOptions.height;
+						if(($this.mWho==JocGame.PLAYER_A && rr==HEIGHT-1) || 
+							($this.mWho==JocGame.PLAYER_B && rr==0)) {
+							king=true;
+						}
+					}
+				}
+			}
			if(!king) {
				if($this.board[pos0]>=0 && $this.pieces[$this.board[pos0]].s==-$this.mWho) {
					var r0,forward;
					if(aGame.g.canCaptureBackward==false) {
						r0=aGame.g.Coord[pos0][0];
						forward=false;
						if(($this.mWho==JocGame.PLAYER_A && r0>=r) || 
							($this.mWho==JocGame.PLAYER_B && r0<=r))
								forward=true;
					}
					if(aGame.g.canCaptureBackward || forward==true) {
						var pos1=aGame.g.Graph[pos0][dir];
						if(pos1!=null && ($this.board[pos1]==-1 || pos1==poss[0])) {
							var keep=true;
							for(var i=0;i<dirs.length;i++)
								if((aGame.g.captureInstantRemove && capts[i]==pos0) ||
									(aGame.g.captureInstantRemove==false && 
										capts[i]==pos0 && dirs[i]==dir0)) {
									keep=false;
									break;
								}
							if(keep) {
								nextPoss.push(pos1);
								nextCapts.push(pos0);
								nextDirs.push(dir0);
							}
						}
					}
				}
			} else { // king
				if(aGame.g.longRangeKing)
					while($this.board[pos0]==-1 || 
						(aGame.g.king180deg && pos0!=null && capts.indexOf(pos0)>=0))
							pos0=aGame.g.Graph[pos0][dir];
				if(pos0!=null) {
					if($this.board[pos0]>=0 && $this.pieces[$this.board[pos0]].s==-$this.mWho) {
						var caught=pos0;
						pos0=aGame.g.Graph[pos0][dir];
						if(aGame.g.kingCaptureShort) {
							if($this.board[pos0]==-1 || pos0==poss[0]) {
								var keep=true;
								for(var i=0;i<dirs.length;i++)
									if(!aGame.g.king180deg) {
										if((aGame.g.captureInstantRemove && 
											capts[i]==caught) ||
											(aGame.g.captureInstantRemove==false && 
											capts[i]==caught && 
													dirs[i]==dir0)) {
											keep=false;
											break;
										}
									} else if(capts[i]==caught) {
										keep=false;
										break;												
									}
								if(keep) {
									nextPoss.push(pos0);
									nextCapts.push(caught);
									nextDirs.push(dir0);
								}
								pos0=aGame.g.Graph[pos0][dir];
							}
						} else {
							while($this.board[pos0]==-1 || pos0==poss[0]) {
								var keep=true;
								for(var i=0;i<dirs.length;i++)
									if((aGame.g.captureInstantRemove && capts[i]==caught) ||
											(aGame.g.captureInstantRemove==false && 
											capts[i]==caught && dirs[i]==dir0)) {
										keep=false;
										break;
									}
								if(keep) {
									nextPoss.push(pos0);
									nextCapts.push(caught);
									nextDirs.push(dir0);
								}
								pos0=aGame.g.Graph[pos0][dir];
							}
						}
					}
				}
			}
			return true;
		});
		if(nextPoss.length==0) {
			if(poss.length>1)
				$this.mMoves.push({ pos: poss, capt: capts });
			break;
		}
		if(!aGame.g.compulsoryCatch && poss.length>1) {
			var poss1=[];
			for(var i=0;i<poss.length;i++)
				poss1.push(poss[i]);
			var capts1=[];
			for(var i=0;i<capts.length;i++)
				capts1.push(capts[i]);
			$this.mMoves.push({ pos: poss1, capt: capts1 });
		}
		if(nextPoss.length==1) {
			pos=nextPoss[0];
			poss.push(pos);
			capts.push(nextCapts[0]);
			dirs.push(nextDirs[0]);
		} else {
			for(var i=0;i<nextPoss.length;i++) {
				var poss1=[];
				for(var j=0;j<poss.length;j++)
					poss1.push(poss[j]);
				poss1.push(nextPoss[i]);
				var capts1=[];
				for(var j=0;j<capts.length;j++)
					capts1.push(capts[j]);
				capts1.push(nextCapts[i]);
				var dirs1=[];
				for(var j=0;j<dirs.length;j++)
					dirs1.push(dirs[j]);
				dirs1.push(nextDirs[i]);
				catchPieces(nextPoss[i],poss1,capts1,dirs1,king);
			}
			break;
		}
	}
}

Нам здорово повезло с тем, что признак дамки передаётся в качестве параметра функции. Генератор ходов выполняет обход дерева всех возможных составных ходов. Если бы признак дамки изменялся в объекте фигуры, пришлось бы заботиться об откате изменений, выполненных в модели самим генератором. В противном случае, программа могла бы вести себя непредсказуемо. Посмотрите, как это делается в Axiom:

Custom Engine

: Custom-Engine ( -- )
	-10000 BestScore !
	0 Nodes !
	$FirstMove
	BEGIN
		$CloneBoard
		DUP $MoveString 
		CurrentMove!
		DUP .moveCFA EXECUTE
		MaxDepth Depth !
		0 EvalCount !
		BestScore @ 10000 turn-offset next-turn-offset Score
		0 5 $RAND-WITHIN +
		BestScore @ OVER <
		IF
			DUP BestScore !
			Score!
			0 Depth!
			DUP $MoveString BestMove!
		ELSE
			DROP
		ENDIF
		$DeallocateBoard
		Nodes ++
		Nodes @ Nodes!
		$Yield
		$NextMove
		DUP NOT
	UNTIL
	DROP
;

Здесь, мы копируем содержимое доски во временный массив (вызовом $CloneBoard), затем выбираем «лучший» ход, после чего удаляем временное состояние доски ($DeallocateBoard). И так — для каждого уровня просмотра! Как бы там ни было, теперь всё работает, как и было задумано:

Не стоит думать, что на этом всё закончено. В Jocly ещё есть, над чем поломать голову! Посмотрите, сможете ли вы сказать, что не так на этом видео игры в «Turkish Draughts»?

Ответ

Это немного запутанная тема. В большинстве современных вариантов шашек, действует правило "Турецкого удара": в процессе сложного взятия, фигуры противника не убираются с доски сразу, а лишь помечаются как взятые. Забираются они все сразу, по завершении хода. Это правило действует практически везде, кроме… "Турецких шашек"! В «Турецких шашках», дамка представляет собой грозную силу. Выполняя взятие, она «расчищает» себе место для последующих ходов. Всего одна дамка может съесть всю армию противника одним ходом!

Судя по видео, в Jocly это не так. На последнем шаге видно, что дамка не может выбрать более длинную цепочку взятий, поскольку ей мешает ранее взятая шашка, не убранная с доски. Люди, далёкие от настольных игр, могут счесть это обстоятельство несущественным, но ни один из серьёзных игроков никогда не станет играть в «Турецкие шашки» по таким правилам! Пока, я не знаю как это исправить. Требуемое исправление сложнее, чем кастомизация, описанная в этой статье. Внеся изменения в генератор ходов, можно заставить дамку «не видеть» ранее взятые шашки, но, кроме того, необходимо обеспечить дамке возможность остановки на полях, занятых взятыми шашками, в том числе и возможность завершения хода на таком поле. Это сложно и я не готов сейчас этим заниматься, но возможно кто-то из читателей предложит работающее решение?

Мы познакомились с ещё одним интересным «движком» для разработки абстрактных настольных игр. У него есть свои ограничения и процесс разработки в нём не прост. Но у него есть свой, совершенно убийственный набор киллер-фич! Он открытый, кросс-платформенный, Web-ориентированный и, самое главное, он всё ещё поддерживается разработчиками! Проект живёт! Включайтесь в него, и, возможно, он будет жить гораздо дольше чем легендарная Zillions of Games.

Автор: GlukKazan

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js