[ブラウザゲームの作り方] 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の状態をセーブする処理を追加してみたいと思います。

このブログを検索

ごあいさつ

このWebサイトは、独自思考で我が道を行くユゲタの少し尖った思考のTechブログです。 毎日興味がどんどん切り替わるので、テーマはマルチになっています。 もしかしたらアイデアに困っている人の助けになるかもしれません。