[ブラウザゲームの作り方] Number-Place(数独)#4「問題表示」

2023年5月23日

ゲーム プログラミング

eyecatch 数値入力がうまくできるようになったら、次に問題となる数値の入力をしたいと思います。 問題の数値が表示されて、数値入力ができるようになったら、基本的なゲームの表示とコントロールができるので、ゲームとしての構造が出来上がります。

問題データの作成

ただ、今回のナンプレゲームは、問題を自動で生成する事はしないので、問題データを事前に作っておく必要があります。 9 x 9 のマス目に、どの数値を置くかというデータは、データ容量を削減するために、bitmap方式で書いて、データ圧縮して、軽量データにするという事は可能ですが、 ゲームが完成した後で、ゲームの問題をどんどん追加していくことを考えると、見た目が分かりやすいデータにした方がいいと思い、jsonの2重配列で作る事にしました。

問題データサンプル

[ [0,0,7, 0,0,0, 2,0,0], [0,0,0, 6,0,4, 0,0,0], [4,0,0, 0,0,2, 0,0,8], [0,0,0, 0,0,1, 0,0,5], [2,0,0, 0,5,0, 0,1,0], [0,0,6, 0,8,0, 0,7,0], [0,8,0, 0,0,0, 0,0,0], [1,0,0, 5,9,0, 8,0,3], [7,2,0, 4,0,0, 0,0,0] ] 0の数字は、空欄になる箇所で、添字的に入れています。 こうすることで、データを見ただけで、どのマスに何の数値が入るのかが分かりやすいですからね。 そしてこれを、複数登録できる配列データとして、1つのjsonファイルに格納して使います。 ちなみに、実際のデータファイルは次のようにしました。

[新規] data/questions.json

初回は全部で12問作っておきました。 idは、今の時点では使わないのでなくても良いのですが、こういうデータを作ったときは、id値を入れておくと後々に助かることが多いので、おまじないのように入れておきました。 [ { "id" : 1, "data" : [ [0,0,7, 0,0,0, 2,0,0], [0,0,0, 6,0,4, 0,0,0], [4,0,0, 0,0,2, 0,0,8], [0,0,0, 0,0,1, 0,0,5], [2,0,0, 0,5,0, 0,1,0], [0,0,6, 0,8,0, 0,7,0], [0,8,0, 0,0,0, 0,0,0], [1,0,0, 5,9,0, 8,0,3], [7,2,0, 4,0,0, 0,0,0] ] }, { "id" : 2, "data" : [ [0,0,1, 0,0,0, 3,0,0], [0,0,0, 9,0,1, 0,0,4], [2,3,0, 0,0,7, 0,6,9], [0,0,8, 0,6,0, 0,7,1], [0,0,0, 0,0,0, 0,0,0], [1,9,0, 0,0,0, 0,0,0], [0,0,0, 8,0,9, 0,0,6], [0,0,0, 3,0,0, 0,4,0], [4,2,0, 0,0,0, 0,0,0] ] }, { "id" : 3, "data" : [ [0,0,2, 0,0,0, 6,0,0], [0,0,0, 3,0,8, 9,5,0], [7,0,8, 0,0,5, 0,0,0], [0,0,0, 0,5,6, 0,0,8], [0,7,0, 0,9,0, 0,2,0], [0,8,0, 0,0,0, 5,0,0], [0,0,0, 9,2,0, 0,0,0], [5,0,3, 0,0,0, 0,0,0], [0,0,0, 0,4,3, 1,0,0] ] }, { "id" : 4, "data" : [ [0,0,4, 0,0,0, 3,0,0], [0,9,0, 0,0,8, 6,7,0], [1,2,0, 0,0,9, 0,0,8], [0,0,0, 0,0,0, 7,2,5], [0,0,0, 0,0,0, 0,0,4], [9,0,0, 0,2,0, 0,0,0], [0,0,0, 5,0,0, 0,0,7], [0,0,0, 0,6,0, 9,4,0], [2,0,8, 0,1,0, 0,3,0] ] }, { "id" : 5, "data" : [ [0,0,2, 0,0,0, 5,0,0], [0,8,6, 0,0,0, 0,0,0], [0,0,0, 5,0,0, 7,1,0], [8,9,0, 0,5,0, 0,0,2], [0,0,4, 0,8,0, 0,3,0], [2,0,0, 0,0,0, 0,0,0], [4,0,0, 0,0,2, 0,0,0], [7,0,0, 0,4,0, 0,8,0], [0,0,9, 0,0,8, 2,6,0] ] }, { "id" : 6, "data" : [ [0,0,7, 0,0,0, 3,0,0], [8,0,0, 0,0,0, 0,4,9], [5,6,0, 0,0,0, 0,0,1], [2,0,3, 0,9,0, 0,0,0], [0,7,0, 0,0,3, 0,0,6], [0,0,5, 0,0,1, 0,0,0], [0,8,0, 1,0,0, 5,0,0], [0,0,4, 0,0,0, 0,9,0], [7,1,0, 2,0,0, 0,0,0] ] }, { "id" : 7, "data" : [ [0,0,9, 0,0,0, 6,0,0], [4,0,0, 2,0,0, 9,0,0], [0,0,0, 0,0,0, 0,0,0], [0,7,5, 4,0,0, 0,0,0], [0,0,0, 9,0,2, 0,8,0], [6,0,0, 0,0,0, 0,2,5], [0,4,0, 0,0,0, 1,9,7], [9,0,0, 0,3,0, 0,0,0], [0,1,0, 0,8,0, 0,0,0] ] }, { "id" : 8, "data" : [ [0,0,6, 0,0,0, 1,0,0], [4,0,0, 1,0,8, 0,5,9], [7,0,0, 2,0,0, 0,0,0], [0,5,0, 9,0,0, 4,0,0], [0,0,0, 0,0,6, 3,9,1], [0,6,7, 0,4,0, 2,0,0], [0,0,0, 0,7,0, 0,0,8], [1,0,0, 0,0,0, 0,7,0], [0,0,0, 4,0,0, 0,0,0] ] }, { "id" : 9, "data" : [ [0,0,6, 0,0,0, 5,0,0], [3,0,1, 0,8,0, 0,6,9], [9,0,0, 0,0,5, 0,1,0], [5,0,0, 6,0,0, 8,0,0], [0,0,8, 0,0,0, 1,0,0], [0,6,0, 8,7,0, 0,0,0], [0,0,0, 0,0,1, 9,5,3], [0,0,0, 4,9,6, 0,0,0], [0,1,0, 0,0,0, 0,0,2] ] }, { "id" : 10, "data" : [ [0,0,9, 0,0,0, 6,0,0], [6,1,0, 0,0,0, 0,2,0], [0,5,0, 0,3,0, 1,0,0], [0,7,0, 5,0,8, 2,6,0], [0,6,2, 0,0,4, 0,0,0], [0,0,0, 0,0,1, 4,0,5], [2,0,0, 0,6,0, 0,5,0], [1,8,0, 0,0,0, 0,9,0], [9,0,0, 0,0,5, 0,0,0] ] }, { "id" : 11, "data" : [ [6,0,0, 4,0,0, 0,0,0], [5,0,0, 0,0,7, 0,9,0], [0,1,0, 0,8,0, 3,0,0], [0,0,2, 7,0,0, 0,0,4], [0,5,0, 0,0,0, 0,6,0], [1,0,0, 0,0,9, 5,0,0], [0,0,9, 0,7,0, 0,4,0], [0,8,0, 3,0,0, 0,0,2], [0,0,0, 0,0,6, 0,0,3] ] }, { "id" : 12, "data" : [ [2,0,1, 0,0,0, 9,0,7], [0,0,0, 1,0,4, 0,0,0], [0,0,8, 0,3,0, 2,0,0], [3,0,0, 0,0,0, 0,0,1], [0,5,0, 0,7,0, 0,3,0], [9,0,0, 0,0,0, 0,0,8], [0,0,9, 0,1,0, 6,0,0], [0,0,0, 2,0,5, 0,0,0], [4,0,7, 0,0,0, 3,0,2] ] } ]

問題表示処理

次に問題を表示する処理のクラスファイルを作りました。 これまでのモジュールの置き換えと、新しいモジュールの追加をして、環境を更新してください。

[新規] js/question.js

import { Main } from '../main.js' import { Element } from './element.js' export class Question{ constructor(){ this.load() } load(){ const xhr = new XMLHttpRequest() xhr.open('get' , Main.data_path , true) xhr.setRequestHeader('Content-Type', 'application/json'); xhr.onreadystatechange = (e => { if(xhr.readyState !== XMLHttpRequest.DONE){return} const status = xhr.status; if (status === 0 || (status >= 200 && status < 400) || e.target.response) { this.datas = JSON.parse(e.target.response) } }).bind(this) xhr.send() } new(num){ Main.question_num = num || Main.question_num const data = this.datas[Main.question_num] if(!data || !data.data){return} this.put_numbers(data.data) } put_numbers(datas){ const tr_lists = Element.tr_lists if(!tr_lists || !tr_lists.length){return} for(let i=0; i<datas.length; i++){ const td_lists = tr_lists[i].getElementsByTagName('td') for(let j=0; j<datas[i].length; j++){ if(datas[i][j]){ td_lists[j].textContent = datas[i][j] td_lists[j].setAttribute('data-status' , 'lock') } else{ td_lists[j].textContent = '' td_lists[j].setAttribute('data-status' , '') } } } } }

[新規] element.js

import { Main } from '../main.js' export class Element{ static get table(){ return document.getElementById(Main.stage_id) } static get tr_lists(){ return this.table.getElementsByTagName('tr') } static get elm_button(){ return document.querySelector('button#btn') } }

[更新] main.js

import { View } from './js/view.js' import { Input } from './js/input.js' import { Question } from './js/question.js' export const Main = { stage_id : 'NumberPlace', data_path : 'data/questions.json', save_name : 'mynt_number_place_save', clear_name : 'mynt_number_place_clear', interval_px : 10, question_num : 0, } function init(){ Main.view = new View() Main.input = new Input() Main.question = new Question() } switch(document.readyState){ case 'complete': case 'interactive': init() break default: window.addEventListener('DOMContetLoaded' , init) break }

[更新] js/input.js

import { Main } from '../main.js' import { Element } from './element.js' export class Input{ constructor(){ this.set_event() } get next_num(){ if(!this.data){return null} const next_num = this.data.num + 1 return next_num > 9 ? 0 : next_num } set_event(){ const btn = Element.elm_button if(btn){ btn.addEventListener('click' , this.click_btn.bind(this)) } if(typeof window.ontouchstart !== 'undefined'){ Element.table.addEventListener('touchstart' , this.touchstart.bind(this)) Element.table.addEventListener('touchmove' , this.touchmove.bind(this)) Element.table.addEventListener('touchend' , this.mouseup.bind(this)) } else{ Element.table.addEventListener('mousedown' , this.mousedown.bind(this)) Element.table.addEventListener('mousemove' , this.mousemove.bind(this)) Element.table.addEventListener('mouseup' , this.mouseup.bind(this)) } } touchstart(e){ this.mousedown(e.touches[0]) } touchmove(e){ e.preventDefault() this.mousemove(e.touches[0]) } mousedown(e){ const cell = e.target.closest('td') if(!cell){return} if(cell.getAttribute('data-status') === 'lock'){return} this.data = { cell : cell, num : Number(cell.textContent || 0), pos : { x : e.pageX, y : e.pageY, }, } } mousemove(e){ if(!this.data){return} const size = Math.abs(e.pageX - this.data.pos.x) if(size < Main.interval_px){return} const num = this.pos2num(size) this.data.cell.textContent = num || '' this.data.num = num this.data.move_flg = true } mouseup(e){ if(!this.data){return} if(!this.data.move_flg){ this.data.num = this.next_num } this.data.cell.textContent = this.data.num || '' delete this.data } // 移動距離を0~9の数値に変換する pos2num(pos){ const num = ~~(pos / Main.interval_px) return num > 9 ? num % 10 : num } click_btn(){ const status = Element.elm_button.getAttribute('data-status') switch(status){ case 'check': break case 'start': default: Element.elm_button.setAttribute('data-status' , 'check') Main.question.new(Main.question_num) break } } }

[更新] index.html

<!DOCTYPE html> <html> <head> <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <title>NumberPlace</title> <link type='text/css' rel='stylesheet' href='style.css' /> <script type='module' src='main.js'></script> </head> <body> <h1>NumberPlace</h1> <div id='NumberPlace'><button id='btn' data-status='start'></button></div> </body> </html>

[更新] style.css

@import 'css/common.css'; @import 'css/table.css'; @import 'css/button.css';

[新規] css/button.css

button#btn{ width : calc(var(--size-stage) + 2px); padding:10px; border:2px solid var(--color-border-2); display:block; margin-bottom:5px; background-color:#ddd; border-radius:4px; cursor:pointer; } button#btn:hover{ background-color:#eee; } button#btn:active{ background-color:#fee; border-color:#FAA; color:#FAA; } button#btn[data-status='start']::before{ content : 'Start'; } button#btn[data-status='check']::before{ content : 'Check'; } button#btn[data-status='disabled']{ pointer-events:none; background-color:#bbb; color:#ddd; }

[新規] css/common.css

#NumberPlace{ --size-stage : 300px; --color-border-1 : black; --color-border-2 : #666; } @media (max-width:500px){ #NumberPlace{ --size-stage : calc(100vw - 2px); } } html,body{ width : 100%; height : 100%; padding : 0; margin : 0; border : 0; outline : 0; scroll-behavior : smooth; } body{ border : 1px solid transparent; } #NumberPlace, #NumberPlace *, #NumberPlace *::before, #NumberPlace *::after{ -webkit-box-sizing : border-box; -moz-box-sizing : border-box; -o-box-sizing : border-box; -ms-box-sizing : border-box; box-sizing : border-box; -webkit-font-smoothing: antialiased; font-family : base-font , sans-serif; } table{ border-collapse:collapse; } .no-select{ -webkit-user-select : none; -moz-user-select : none; -ms-user-select : none; user-select : none; } ul,ol,li{ list-style : none; margin : 0; padding : 0; } .hidden{ display : none!important; } button{ background : none; border : none; outline : none; -webkit-appearance : none; -moz-appearance : none; appearance : none; } .flex{ display : flex; } input[type='text'], input[type='number']{ outline : 0; } .right{ text-align : right; }

[新規] css/table.css

table{ border-collapse:collapse; user-select: none; } td{ padding:0; margin:0; } #NumberPlace td{ width : calc(var(--size-stage) / 9); height : calc(var(--size-stage) / 9); border : 1px solid var(--color-border-2); cursor : pointer; text-align : center; vertical-align : middle; background-color : white; font-size : 20px; font-weight : bold; } #NumberPlace tr:nth-child(1){ border-top : 2px solid #000; } #NumberPlace tr:nth-child(3n){ border-bottom : 2px solid #000; } #NumberPlace td:nth-child(1){ border-left : 2px solid #000; } #NumberPlace td:nth-child(3n){ border-right : 2px solid #000; } #NumberPlace td[data-status='lock']{ color:#66F; } @media (hover:hover){ #NumberPlace td:hover{ background-color : #DDD; } } @media (hover:none){ }

解説とポイント

1. main.jsでの初期処理

ゲームのコントロール部分のmain.jsでは、今回新しく作ったquestion.jsを読み込んで、クラスを実行しています。

2. question.jsのインスタンス起動処理(constructor)

その流れで、question.jsのクラスQuestionでは、最初に、data/question.jsonをajax処理で読み込む load処理が実行されます。 読み込みが正常に完了すると、this.datasにデータが格納されて、以後はクラス内のdatas変数から参照できるようになります。

3. input.jsにstartボタンイベント登録

イベント処理を追加して、startボタンを押した時に、question.jsのnew関数を実行して、新規に問題を表示する処理に進みます。

4. question.jsのnew関数

今現在の問題がデータに登録してある配列の何番目かを格納しているのが、Main.question_numという変数です。 指定された問題番号または、事前に格納してある番号を元にajaxで読み込んだデータの中から問題データのみを抽出します。 それを配列に沿って、テーブルタグの中のセルを書き換えていく処理をしています。 ただし、0の時は、何も書き込まない(ブランクを書き込む)処理をすれば、問題の表示が完了になります。 注意点として、問題で数字が書き込まれるセルに、data-status = 'locked'という属性を付けています。 これは、問題の数字は、変更が出来ないようにする数値ロック機能にしていて、前回作った、input.jsでの数値の書き換え処理を防止するif文を入れています。

あとがき

この時点で、ナンプレのゲーム自体は遊べる状態になっていますが、実際に数字を置いていってそれが正解なのか、間違っているのか、判定する必要があります。 その判定処理を次回やろうと思ったのですが、その前に、配置したデータをブラウザをリロードしても残しておくために、次回はtableの状態をセーブする処理を追加してみたいと思います。