0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

マカオで遊んださいころカジノSIC BOを自宅で再現してみた

0
Posted at

家の中を小さなカジノにプロジェクト
マカオで遊んださいころカジノSIC BOを自宅で再現してみた。
Raspberry Piでさいころを撮影し、出目を判別するようにしました。

●使用したマイコン
①Arduino Uno: サイコロ振動モーター制御用
②Raspberry Pi Model B: Wifiサーバ構築用

●流れ
Arduino Uno
 ↓End信号送信(UART)
Raspberry Pi
・さいころ撮影,出目検出
・出目結果をArduinoとWifiで送信
 ↓出目結果送信(UART)
Arduino Uno
・LCDに結果表示
・20秒待ってサイコロをシャッフル
・End信号を送信
 ↓End信号送信(UART)
Raspberry Pi

●(参考)コード
★Arduino Uno

#include <LiquidCrystal.h>

// LCD
LiquidCrystal lcd(7, 8, 9, 10, 11, 12);

// ピン
const int motorPin = 4;
const int buttonPin = 2;
//const int ledPin = 13;

// 状態管理
char input[32];
byte idx = 0;

unsigned long displayTime = 0;
bool waitingEnd = false;
bool rolling = false;
unsigned long rollTime = 0;

void setup() {
  Serial.begin(9600);

  pinMode(motorPin, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);
  //pinMode(ledPin, OUTPUT);

  lcd.begin(16, 2);
  lcd.clear();
  lcd.print("Dice Ready");
}

void loop() {
  // ===== ボタン =====
  if (digitalRead(buttonPin) == LOW) {
    Serial.println("END");
    delay(300); // チャタリング対策(OK)
  }

  // ===== シリアル受信 =====
  while (Serial.available()) {
    char c = Serial.read();

    if (c == '\n') {
      input[idx] = '\0';
      processDice(input);
      idx = 0;
    } else if (idx < sizeof(input) - 1) {
      input[idx++] = c;
    }
  }

  // ===== 5秒後にサイコロ回転 =====
  if (waitingEnd && millis() - displayTime >= 20000) {
    startRoll();
    waitingEnd = false;
  }
}

// ==============================

void processDice(const char* data) {
  if (strlen(data) == 0) return;

  lcd.clear();
  lcd.setCursor(0, 0);
  lcd.print("Dice Result");

  lcd.setCursor(0, 1);
  lcd.print("                "); // 行クリア
  lcd.setCursor(0, 1);
  lcd.print(data);

  displayTime = millis();
  waitingEnd = true;
}

void startRoll() {
  //digitalWrite(ledPin, HIGH);
  digitalWrite(motorPin, HIGH);
  delay(500);
  digitalWrite(motorPin, LOW);
  delay(1000);
  //digitalWrite(ledPin, LOW);
  Serial.println("END");
  delay(300); // チャタリング対策(OK)
  lcd.clear();
  lcd.print("Ready");
}

★Raspberry Pi

import cv2
import numpy as np
import time
import serial
import threading
import os
from flask import Flask, jsonify, render_template
from picamera2 import Picamera2

# =========================
# Serial(Arduino)
# =========================
SERIAL_PORT = "/dev/ttyACM0"
BAUDRATE = 9600

try:
    ser = serial.Serial(SERIAL_PORT, BAUDRATE, timeout=0.1)
    time.sleep(2)
    print("Arduino connected")
except Exception as e:
    print("Arduino connection failed:", e)
    ser = None

# =========================
# Flask
# =========================
app = Flask(__name__)

latest_dice = ""
status = "WAIT_END"

@app.route("/")
def index():
    return render_template("sicbo.html")

@app.route("/dice")
def dice():
    return jsonify({
        "dice": latest_dice,
        "status": status
    })

def run_flask():
    app.run(host="0.0.0.0", port=5000, debug=False, use_reloader=False)

# =========================
# サイコロ検出(枠描画付き)
# =========================
def detect_dice(image):

    blur = cv2.GaussianBlur(image, (9, 9), 0)
    hsv = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV)

    kernel = np.ones((5, 5), np.uint8)
    out = image.copy()

    # --- 出目検出 ---
    lower_blue = np.array([105, 100, 160])
    upper_blue = np.array([150, 255, 255])
    mask_blue = cv2.inRange(hsv, lower_blue, upper_blue)

    lower_red1 = np.array([0, 57, 220])
    upper_red1 = np.array([40, 80, 255])
    lower_red2 = np.array([140, 57, 220])
    upper_red2 = np.array([180, 80, 255])

    mask_red = cv2.bitwise_or(
        cv2.inRange(hsv, lower_red1, upper_red1),
        cv2.inRange(hsv, lower_red2, upper_red2)
    )

    mask_pip = cv2.bitwise_or(mask_blue, mask_red)
    mask_pip = cv2.morphologyEx(mask_pip, cv2.MORPH_CLOSE, kernel)

    contours_pip, _ = cv2.findContours(
        mask_pip, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    pip_centers = []

    for cnt in contours_pip:
        area = cv2.contourArea(cnt)
        if area < 50:
            continue

        x, y, w, h = cv2.boundingRect(cnt)

        # 赤枠(出目)
        cv2.rectangle(out, (x, y), (x + w, y + h), (0, 0, 255), 3)

        cx = x + w // 2
        cy = y + h // 2
        pip_centers.append((cx, cy))

    # --- サイコロ本体 ---
    lower_gray = np.array([0, 10, 230])
    upper_gray = np.array([180, 100, 255])

    mask_dice = cv2.inRange(hsv, lower_gray, upper_gray)
    mask_dice = cv2.morphologyEx(mask_dice, cv2.MORPH_CLOSE, kernel)

    contours_dice, _ = cv2.findContours(
        mask_dice, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )

    dice_results = []

    for cnt in contours_dice:

        area = cv2.contourArea(cnt)
        if area < 600:
            continue

        x, y, w, h = cv2.boundingRect(cnt)

        # 緑枠(サイコロ)
        cv2.rectangle(out, (x, y), (x + w, y + h), (0, 255, 0), 4)

        count = 0
        for cx, cy in pip_centers:
            if x < cx < x + w and y < cy < y + h:
                count += 1

        if count > 0:
            dice_results.append(count)

        # 出目表示
        cv2.putText(
            out,
            str(count),
            (x, y - 10),
            cv2.FONT_HERSHEY_SIMPLEX,
            1.0,
            (0, 255, 0),
            2
        )

    return dice_results, out

# =========================
# END信号待ち
# =========================
def wait_for_end():

    if ser and ser.in_waiting:
        line = ser.readline().decode("utf-8").strip()

        if line == "END":
            print("END signal received")
            return True

    return False

# =========================
# メイン処理
# =========================
def main_loop():

    global latest_dice, status

    picam2 = Picamera2()

    config = picam2.create_still_configuration(
        main={"size": (1920, 1080), "format": "BGR888"}
    )

    picam2.configure(config)
    picam2.start()

    time.sleep(1)

    picture_folder = os.path.join(os.path.dirname(__file__), "picture")

    if not os.path.exists(picture_folder):
        os.makedirs(picture_folder)

    print("System ready")

    while True:

        if not wait_for_end():
            time.sleep(0.05)
            continue

        status = "BUSY"
        latest_dice = ""

        # 揺れ対策
        time.sleep(0.3)

        # 1秒待って撮影
        time.sleep(1)

        image = picam2.capture_array()

        dice, result_image = detect_dice(image)

        # 画像保存
        filename = os.path.join(
            picture_folder,
            "dice_" + str(int(time.time())) + ".jpg"
        )

        cv2.imwrite(filename, result_image)

        if len(dice) == 3 and all(1 <= d <= 6 for d in dice):

            latest_dice = ",".join(map(str, dice))
            status = "DONE"

            print("Detected:", latest_dice)

            if ser:
                ser.write((latest_dice + "\n").encode())

        else:

            latest_dice = "Nocount"
            status = "NG"

            print("Nocount")

            if ser:
                ser.write(b"Nocount\n")

# =========================
# 起動
# =========================
if __name__ == "__main__":

    threading.Thread(target=run_flask, daemon=True).start()

    main_loop()

★HTML(ファイル名を"sicbo.html")

<!DOCTYPE html>

<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=0.85">
<title>骰寶 Sic Bo</title>

<style>
body{
margin:0;
background:#000;
color:#d4af37;
font-family:"Times New Roman",serif;
transform: scale(0.55);
transform-origin: top center;
}

h1{
text-align:center;
padding:8px;
margin:0;
font-size:22px;
}

.section-title{
margin:10px 8px 6px;
font-size:16px;
border-bottom:1px solid #d4af37;
}

.grid-2,.grid-3,.grid-4,.grid-6{
display:grid;
gap:6px;
padding:0 8px;
}
.grid-2{grid-template-columns:repeat(2,1fr);}
.grid-3{grid-template-columns:repeat(3,1fr);}
.grid-4{grid-template-columns:repeat(4,1fr);}
.grid-6{grid-template-columns:repeat(6,1fr);}

.bet{
background:#111;
border:2px solid #d4af37;
border-radius:8px;
padding:6px 2px 4px;
text-align:center;
font-size:14px;
cursor:pointer;
color:#d4af37;
line-height:1.2;
}

.bet small{
display:block;
font-size:10px;
margin-top:3px;
color:#aaa;
}

.bet.selected{
background:#d4af37;
border-color:#ffd700;
color:#000;
}

.bet.win{
background:#006400;
border-color:#00ff66;
color:#fff;
}

.amount{
font-size:11px;
color:#00ffff;
}

#result{
margin:10px;
padding:8px;
font-size:28px;
text-align:center;
border:2px solid #d4af37;
border-radius:10px;
background:#111;
}

#timerBarWrap{
margin:6px 10px;
height:10px;
background:#222;
border-radius:6px;
overflow:hidden;
border:1px solid #d4af37;
}

#timerBar{
height:100%;
width:100%;
background:#00ff66;
transition:width 1s linear;
}

</style>

</head>

<body>

<h1>🎲 骰寶 SIC BO</h1>

<div style="text-align:center;margin:6px;font-size:18px">
餘額:$<span id="balance">1000</span>
總下注:$<span id="totalBet">0</span>
</div>

<div style="text-align:center;margin:6px">
Chip:
<button onclick="setChip(10)">10</button>
<button onclick="setChip(50)">50</button>
<button onclick="setChip(100)">100</button>
<button onclick="setChip(500)">500</button>

<button onclick="undoBet()">取消下注</button>
<button onclick="clearBets()">清空下注</button>

</div>

<div style="margin:10px;padding:8px;border:1px solid
#d4af37;border-radius:10px;text-align:center">
出目入力(例:1,1,3)<br>
<input id="manualDice" style="width:110px;font-size:16px;text-align:center">
<button onclick="applyDice()">反映</button>
</div>

<div id="result">🎲 ---</div>

<div id="timerBarWrap">
  <div id="timerBar"></div>
</div>

<div class="section-title">大小</div>
<div class="grid-2">
<div class="bet" data-type="big">大<small>1:1</small></div>
<div class="bet" data-type="small">小<small>1:1</small></div>
</div>

<div class="section-title">單雙</div>
<div class="grid-2">
<div class="bet" data-type="odd">單<small>1:1</small></div>
<div class="bet" data-type="even">雙<small>1:1</small></div>
</div>

<div class="section-title">單骰</div>
<div class="grid-6">
<div class="bet" data-type="single-1">1<small>1:1</small></div>
<div class="bet" data-type="single-2">2<small>1:1</small></div>
<div class="bet" data-type="single-3">3<small>1:1</small></div>
<div class="bet" data-type="single-4">4<small>1:1</small></div>
<div class="bet" data-type="single-5">5<small>1:1</small></div>
<div class="bet" data-type="single-6">6<small>1:1</small></div>
</div>

<div class="section-title">點數</div>
<div class="grid-4">
<script>
const sumOdds={4:"50:1",5:"18:1",6:"14:1",7:"12:1",8:"8:1",
9:"6:1",10:"6:1",11:"6:1",12:"6:1",
13:"8:1",14:"12:1",15:"14:1",16:"18:1",17:"50:1"};
for(let i=4;i<=17;i++){
document.write(`<div class="bet"
data-type="sum-${i}">${i}<small>${sumOdds[i]}</small></div>`);
}
</script>
</div>

<div class="section-title">對子</div>
<div class="grid-3">
<script>
for(let i=1;i<=6;i++){
document.write(`<div class="bet"
data-type="pair-${i}">${i}-${i}<small>8:1</small></div>`);
}
</script>
</div>

<div class="section-title">豹子</div>
<div class="grid-4">
<div class="bet" data-type="any-triple">任意<small>24:1</small></div>
<script>
for(let i=1;i<=6;i++){
document.write(`<div class="bet"
data-type="triple-${i}">${i}-${i}-${i}<small>150:1</small></div>`);
}
</script>
</div>

<div class="section-title">組合</div>
<div class="grid-6">
<script>
for(let a=1;a<=6;a++){
for(let b=a+1;b<=6;b++){
document.write(`<div class="bet"
data-type="combo-${a}-${b}">${a}-${b}<small>5:1</small></div>`);
}}
</script>
</div>

<script>

let balance=1000
let chip=10
let betHistory=[]
let isBettingOpen = true
let countdown = 20
let timer = null
let lastStatus = ""

const payout={
big:1,small:1,odd:1,even:1,
"single-1":1,"single-2":1,"single-3":1,
"single-4":1,"single-5":1,"single-6":1,
"pair-1":8,"pair-2":8,"pair-3":8,
"pair-4":8,"pair-5":8,"pair-6":8,
"any-triple":24,
"triple-1":150,"triple-2":150,"triple-3":150,
"triple-4":150,"triple-5":150,"triple-6":150
}

const sumPay={
4:50,5:18,6:14,7:12,8:8,
9:6,10:6,11:6,12:6,
13:8,14:12,15:14,16:18,17:50
}

function setChip(v){chip=v}

document.addEventListener("click",e=>{
if(e.target.classList.contains("bet")){

if(!isBettingOpen)return
if(balance<chip)return

let bet=parseInt(e.target.dataset.bet||0)
bet+=chip
balance-=chip

betHistory.push({el:e.target,amount:chip})

e.target.dataset.bet=bet
e.target.classList.add("selected")

let amt=e.target.querySelector(".amount")

if(!amt){
amt=document.createElement("div")
amt.className="amount"
e.target.appendChild(amt)
}

amt.innerText="$"+bet

updateTotal()

}
})

function undoBet(){

if(betHistory.length===0)return

let last=betHistory.pop()

let el=last.el
let amount=last.amount

let bet=parseInt(el.dataset.bet||0)

bet-=amount
balance+=amount

if(bet<=0){
bet=0
el.classList.remove("selected")
}

el.dataset.bet=bet

let amt=el.querySelector(".amount")

if(amt) amt.innerText=bet?("$"+bet):""

updateTotal()

}

function updateTotal(){

let total=0

document.querySelectorAll(".bet").forEach(b=>{
total+=parseInt(b.dataset.bet||0)
})

document.getElementById("totalBet").innerText=total
document.getElementById("balance").innerText=balance

}

function clearBets(){

document.querySelectorAll(".bet").forEach(b=>{
b.dataset.bet=0
b.classList.remove("selected")

let a=b.querySelector(".amount")
if(a)a.innerText=""
})

betHistory=[]
updateTotal()

}

function startCountdown(){

isBettingOpen = true

countdown = 20
const maxTime = 20

document.getElementById("result").innerText = "⏳ 下注時間: " + countdown

document.getElementById("timerBar").style.width = "100%"

timer = setInterval(()=>{

countdown--

let percent = (countdown / maxTime) * 100
document.getElementById("timerBar").style.width = percent + "%"

if(countdown > 0){
    document.getElementById("result").innerText = "⏳ 下注時間: " + countdown
}else{
    clearInterval(timer)
    isBettingOpen = false
    document.getElementById("result").innerText = "⛔ 下注停止"
}

},1000)

}

function applyDice(){

document.querySelectorAll(".bet").forEach(b=>b.classList.remove("win"))

const dice=document.getElementById("manualDice").value
.split(",").map(n=>+n.trim()).filter(n=>n>=1&&n<=6)

if(dice.length!==3)return

document.getElementById("result").innerText="🎲 "+dice.join(",")

const sum=dice.reduce((a,b)=>a+b,0)
const cnt={}
dice.forEach(d=>cnt[d]=(cnt[d]||0)+1)
const isTriple=Object.values(cnt).includes(3)

document.querySelectorAll(".bet").forEach(b=>{

const t=b.dataset.type

if(t==="big"&&sum>=11&&sum<=17&&!isTriple)b.classList.add("win")
if(t==="small"&&sum>=4&&sum<=10&&!isTriple)b.classList.add("win")
if(t==="odd"&&sum%2===1)b.classList.add("win")
if(t==="even"&&sum%2===0)b.classList.add("win")

if(t.startsWith("single-")&&dice.includes(+t.split("-")[1]))b.classList.add("win")
if(t.startsWith("sum-")&&sum===+t.split("-")[1])b.classList.add("win")
if(t.startsWith("pair-")&&cnt[+t.split("-")[1]]>=2)b.classList.add("win")
if(t==="any-triple"&&isTriple)b.classList.add("win")
if(t.startsWith("triple-")&&cnt[+t.split("-")[1]]===3)b.classList.add("win")

if(t.startsWith("combo-")){
const [a,c]=t.split("-").slice(1).map(Number)
if(dice.includes(a)&&dice.includes(c))b.classList.add("win")
}

})

let winMoney=0

document.querySelectorAll(".bet.win").forEach(b=>{

let bet=parseInt(b.dataset.bet||0)
if(!bet)return

let type=b.dataset.type
let pay=0

if(payout[type]) pay=payout[type]

if(type.startsWith("sum-")){
let n=parseInt(type.split("-")[1])
pay=sumPay[n]
}

if(type.startsWith("combo-")) pay=5

let win=bet*(pay+1)

winMoney+=win

})

balance+=winMoney

updateTotal()

setTimeout(clearBets,1500)

}

async function updateDice(){
try{

const res=await fetch("/dice")
const data=await res.json()

if (data.status === "BUSY" && lastStatus !== "BUSY") {
    isBettingOpen = false
}

if (data.status === "DONE" && lastStatus !== "DONE") {

    document.getElementById("manualDice").value = data.dice

    applyDice()

    setTimeout(()=>{
        startCountdown()
    },1500)
}

lastStatus = data.status

}catch(e){
console.log("fetch error",e)
}
}

setInterval(updateDice,1000)

startCountdown()

</script>

</body>
</html>
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?