게임 규칙
전략 해설 보기대결상대
TURN
ROUND
GAME
게임 로그
코드 아레나
돌 하나를 어디에 둘지 어떻게 정하나
오목 코드가 하는 일은 매 턴 좌표 하나를 돌려주는 것뿐입니다. 그런데 225칸을 다 볼 필요는 없습니다. 다음 돌은 이미 놓인 돌 근처에 놓이니까요. 문제는 그 후보 중 어디가 좋은지 어떻게 점수를 매기냐입니다. 아래는 그 점수 매기는 방식 셋과 각각이 실제로 낸 성적입니다.
1. 이웃 중에서 아무 데나 (기본 샘플)
화면에 실려 있는 기본 코드가 이렇게 합니다. 첫 수는 판 가운데에 두고, 그다음부터는 놓인 돌의 이웃 중에서 무작위로 하나를 고릅니다. 판을 들고 있고 후보를 좁히는 뼈대는 이미 다 들어 있습니다. 없는 것은 점수뿐입니다.
RANDOM 반반 LEVEL 1·2·3 전패
RANDOM과도 반반입니다. 둘 다 아무 생각이 없기 때문입니다. 뼈대는 쓸 만하니 여기에 점수만 넣으면 됩니다.
2. 줄의 길이를 센다
가장 먼저 떠오르는 점수는 길이입니다. 후보 자리에 내 돌을 놓아 보고 네 방향(가로·세로·대각선 둘)에서 나오는 가장 긴 줄을 셉니다. 같은 자리에 상대 돌을 놓아 보고 상대의 가장 긴 줄도 셉니다. 그러면 "내가 늘릴 자리"와 "상대를 끊을 자리"를 한 자에 올려 비교할 수 있습니다.
var SIZE = 15;
var EMPTY = 0, ME = 1, OP = 2;
var board = null;
var candidates = null;
function onGameStart() {}
function onRoundStart() {
var r;
board = [];
for (r = 0; r < SIZE; r += 1) {
board.push([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);
}
candidates = [];
}
function inside(v) { return v >= 0 && v < SIZE; }
// 판에 돌을 놓고, 그 이웃 여덟 칸을 다음 후보에 넣는다.
// 오목은 놓인 돌 근처에 다음 돌이 놓이므로 판 전체를 볼 필요가 없다.
function place(r, c, who) {
var diffs = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
var at = candidates.indexOf(r * SIZE + c);
var i, nr, nc;
board[r][c] = who;
if (at >= 0) { candidates.splice(at, 1); }
for (i = 0; i < diffs.length; i += 1) {
nr = r + diffs[i][0];
nc = c + diffs[i][1];
if (inside(nr) && inside(nc) && board[nr][nc] === EMPTY
&& candidates.indexOf(nr * SIZE + nc) < 0) {
candidates.push(nr * SIZE + nc);
}
}
}
// (r,c)에 who의 돌을 놓았다고 가정할 때 네 방향에서 나오는 가장 긴 줄.
function runLength(r, c, who) {
var dirs = [[0,1],[1,0],[1,1],[1,-1]];
var best = 1;
var i, side, len, nr, nc;
for (i = 0; i < dirs.length; i += 1) {
len = 1;
for (side = -1; side <= 1; side += 2) {
nr = r + dirs[i][0] * side;
nc = c + dirs[i][1] * side;
while (inside(nr) && inside(nc) && board[nr][nc] === who) {
len += 1;
nr += dirs[i][0] * side;
nc += dirs[i][1] * side;
}
}
if (len > best) { best = len; }
}
return best;
}
function onTurnStart(data) {
var best = -1, bestScore = -1;
var i, key, r, c, score, loc;
if (data && data.opponentchoice) {
place(data.opponentchoice[0], data.opponentchoice[1], OP);
}
if (!candidates.length) {
place(7, 7, ME);
return [7, 7];
}
for (i = 0; i < candidates.length; i += 1) {
key = candidates[i];
r = Math.floor(key / SIZE);
c = key % SIZE;
// 내 줄을 늘리는 값과 상대 줄을 끊는 값을 그냥 더한다.
score = runLength(r, c, ME) + runLength(r, c, OP);
if (score > bestScore) { bestScore = score; best = key; }
}
loc = [Math.floor(best / SIZE), best % SIZE];
place(loc[0], loc[1], ME);
return loc;
}
function onRoundEnd(result) {}
function onGameEnd(result) {}
RANDOM 전승 LEVEL 1·2·3 전패
RANDOM은 전승입니다. 그런데 LEVEL 1에는 전패합니다. 공격만 해도, 수비만 해도, 이렇게 둘을 더해도 결과가 같았습니다. 길이를 세는 동안은 무엇을 더해도 넘지 못했습니다. 빠진 것이 하나 있습니다.
3. 열린 줄과 막힌 줄은 값이 다르다
길이가 3인 줄 둘을 놓고 보면 이유가 보입니다.
· ● ● ● ·
양쪽이 열려 있다. 다음 턴에 4가 되고, 그다음에 이긴다.
○ ● ● ● ·
한쪽이 막혀 있다. 더 뻗을 곳이 한쪽뿐이라 값이 훨씬 낮다.
둘 다 길이는 3입니다. 그런데 위는 이겨 가는 모양이고 아래는 거의 죽은 모양입니다. 길이만 세면 이 둘이 같은 점수를 받습니다. 그래서 줄이 끝난 자리가 비어 있는지를 함께 세고, (길이, 열린 끝)으로 값을 매깁니다. 그리고 같은 길이라도 내 값을 상대 값보다 크게 둡니다. 내가 이기는 자리가 상대를 막는 자리보다 앞이기 때문입니다.
var SIZE = 15;
var EMPTY = 0, ME = 1, OP = 2;
var board = null;
var candidates = null;
function onGameStart() {}
function onRoundStart() {
var r;
board = [];
for (r = 0; r < SIZE; r += 1) {
board.push([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]);
}
candidates = [];
}
function inside(v) { return v >= 0 && v < SIZE; }
// 판에 돌을 놓고, 그 이웃 여덟 칸을 다음 후보에 넣는다.
// 오목은 놓인 돌 근처에 다음 돌이 놓이므로 판 전체를 볼 필요가 없다.
function place(r, c, who) {
var diffs = [[-1,-1],[-1,0],[-1,1],[0,-1],[0,1],[1,-1],[1,0],[1,1]];
var at = candidates.indexOf(r * SIZE + c);
var i, nr, nc;
board[r][c] = who;
if (at >= 0) { candidates.splice(at, 1); }
for (i = 0; i < diffs.length; i += 1) {
nr = r + diffs[i][0];
nc = c + diffs[i][1];
if (inside(nr) && inside(nc) && board[nr][nc] === EMPTY
&& candidates.indexOf(nr * SIZE + nc) < 0) {
candidates.push(nr * SIZE + nc);
}
}
}
// (r,c)에 who의 돌을 놓았다고 가정할 때, 네 방향의 줄을 길이와
// 열린 끝 개수로 함께 돌려준다. 여기가 2번과 달라지는 곳이다.
function lines(r, c, who) {
var dirs = [[0,1],[1,0],[1,1],[1,-1]];
var out = [];
var i, side, len, open, nr, nc;
for (i = 0; i < dirs.length; i += 1) {
len = 1;
open = 0;
for (side = -1; side <= 1; side += 2) {
nr = r + dirs[i][0] * side;
nc = c + dirs[i][1] * side;
while (inside(nr) && inside(nc) && board[nr][nc] === who) {
len += 1;
nr += dirs[i][0] * side;
nc += dirs[i][1] * side;
}
// 줄이 끝난 자리가 비어 있으면 그 방향은 열려 있다.
if (inside(nr) && inside(nc) && board[nr][nc] === EMPTY) {
open += 1;
}
}
out.push({len: len, open: open});
}
return out;
}
// 열린 3은 다음 턴에 4가 되지만 막힌 3은 그렇지 않다. 그래서 같은 길이라도
// 값이 다르다. 내 값이 상대 값보다 크다. 이기는 자리를 막는 자리보다 앞세운다.
function lineScore(list, mine) {
var best = 0, sum = 0;
var i, line, value;
for (i = 0; i < list.length; i += 1) {
line = list[i];
if (line.len >= 5) {
value = mine ? 1000000 : 100000;
} else if (line.len === 4) {
value = line.open >= 2 ? (mine ? 50000 : 9000) : (mine ? 8000 : 4000);
} else if (line.len === 3) {
value = line.open >= 2 ? (mine ? 3000 : 2500) : (mine ? 200 : 150);
} else if (line.len === 2) {
value = line.open >= 2 ? (mine ? 100 : 80) : (mine ? 10 : 8);
} else {
value = line.open >= 2 ? 5 : 1;
}
sum += value;
if (value > best) { best = value; }
}
// 가장 값진 한 줄에 나머지를 더한다. 두 방향이 동시에 걸리는 자리가 좋다.
return best + sum;
}
function onTurnStart(data) {
var best = -1, bestScore = -1;
var i, key, r, c, score, loc;
if (data && data.opponentchoice) {
place(data.opponentchoice[0], data.opponentchoice[1], OP);
}
if (!candidates.length) {
place(7, 7, ME);
return [7, 7];
}
for (i = 0; i < candidates.length; i += 1) {
key = candidates[i];
r = Math.floor(key / SIZE);
c = key % SIZE;
// 길이를 그냥 더하지 않고, 열린 끝까지 본 값을 더한다.
score = lineScore(lines(r, c, ME), true)
+ lineScore(lines(r, c, OP), false);
if (score > bestScore) { bestScore = score; best = key; }
}
loc = [Math.floor(best / SIZE), best % SIZE];
place(loc[0], loc[1], ME);
return loc;
}
function onRoundEnd(result) {}
function onGameEnd(result) {}
RANDOM · LEVEL 1 전승 LEVEL 2·3 전패
이 한 가지를 넣으니 LEVEL 1이 전승으로 바뀌었습니다. 코드에서 달라진 곳은 줄을 세는 함수와 점수를 매기는 함수 둘뿐입니다. 나머지는 2번과 같습니다.
4. 여기서부터는 당신 차례
LEVEL 2와 LEVEL 3은 아직 이 코드로 넘지 못합니다. 이 코드가 보는 것은 한 수 뒤까지입니다. 상대가 그다음에 무엇을 할지는 보지 않습니다. 그리고 양쪽이 열린 3을 두 개 동시에 만드는 자리처럼, 한 수로 막을 수 없는 모양을 노리지도 않습니다. 위 코드를 에디터에 붙여 넣고 여기서부터 고쳐 보세요.
대전은 이렇게 돕니다
- 코드배틀은 내가 작성한 코드와 상대가 작성한 코드가 일대일로 대결하는 방식이며, 언어는 JAVASCRIPT 입니다.
- 코드를 작성하고, 대결상대를 RANDOM, LEVEL1, LEVEL2, LEVEL3, 온라인 상대 중에서 선택한 후 게임시작 버튼을 누르면 대결이 시작됩니다.
- 대결은 game이며, 1 game은 6 round로 구성되고, 1 round는 한쪽이 이기거나 모든 좌표가 점유될 때까지 turn이 계속됩니다.
오목은 훅이 다섯 개입니다. onTurnEnd가 없고, 상대가 둔 좌표는 다음 onTurnStart의 data.opponentchoice로 옵니다. 위 코드는 다섯 개를 모두 담고 있어 그대로 붙여 넣으면 돌아갑니다.
성적은 상대별로 20판(라운드 120번)을 돌려 측정했고 저장소의 테스트가 실제 규칙 엔진으로 매번 다시 확인합니다. LEVEL 3을 뺀 상대에는 무작위가 섞여 있어 판마다 달라지므로, 전승·전패는 20판에서 95% 이상을 뜻합니다.