Solidity - 가위바위보 게임 실습

김도영·2022년 7월 12일
0

수도코드

가위바위보 컨트랙트는 다음의 퍼블릭 함수를 가지고 있다.

  • createRoom: 가위바위보 게임을 하기 위한 방을 만든다.
  • joinRoom: 만들어진 방에 참가한다.
  • checkTotalPay: 만들어진 방들의 총 배팅 금액을 확인한다.
  • paout: 게임을 마친다. 게임의 결과에 따라 배팅 금액을 송금한다.

가위바위보 컨트랙트는 다음과 같이 진행된다.

  1. 방장(originator)가 createRoom을 호출한다.
    • 방장은 인자로 자신이 낼 가위/바위/보 값과 배팅 금액을 넘겨준다.
    • crateRoom은 새로운 방을 만들고, 방의 번호를 리턴한다.
  2. 참가자(taker)는 joinRoom을 호출한다.
    • 참가자느 인자로 참여할 방 번호, 자신이 낼 가위/바위/보 값과 배팅 금액을 넘겨준다.
    • joinRoom은 참가자를 방에 참여시킨다.
    • joinRoom은 방장과 참가자의 가위/바위/보 값을 확인하고 해당 방의 승자를 설정한다.
  3. 방장(originator) 혹은 참가자(taker)는 checkTotalPay함수를 호출한다.
    • 인자로 방 번호를 입력하면 해당 방에 배팅 금액을 확인한다.
  4. 방장(originator) 혹은 참가자(taker)는 payout 함수를 호출한다.
    • 인자로 게임을 끝낼 방 번호를 넘겨준다.
    • 게임의 결과에 따라 배팅 금액을 송금한다.

사용자와 게임 구조체 생성

사전 컨트랙트 틀 작성

  • SPDX 라이센스는 MIT로 설정한다.
  • pragma 버전은 0.8.7를 사용한다.
  • 컨트랙트의 이름은 RPS 이다.
  • 해당 컨트랙트가 송금을 진행하기 위해 생성자 함수에 payable 키워드를 사용해 송금이 가능하다는 것을 명시한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
}

플레이어 구조체 만들기

게임에서 각 플레이어의 중소와 배팅 금액을 알고 있어야 한다.
따라서 플레이어 구조체는 다음과 같이 작성할 수 있다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
    
    struct Player {
    	address payable addr; // 주소
        uint256 playerBetAmount; // 배팅 금액
    }
}

모든 플레이어는 자신이 낸 가위/바위/보 값이 있을 것이다. 플레이어가 낸 값은 "아직 내지 않은 상태", "가위", "바위", "보" 외에는 있어서는 안된다. 따라서 `enum` 키워드를 사용하여 위의 값 외의 값을 내는 경우 예외가 발생하도록 한다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
    
    enum Hand { // 가위, 바위, 보 값에 대한 enum
    	rock, paper, scissors
    }
    
    struct Player {
    	address payable addr; // 주소
        uint256 playerBetAmount; // 배팅 금액
        Hand hand; // 플레이어가 낸 가위, 바위, 보 값
    }
}

또한 플레이어는 게임의 결과에 따른 상태가 있을 것이다. 상태에는 "대기중", "승리", "비김", "패배" 총 4가지의 상태가 있고 그 외에는 없으므로 역시 enum을 사용한다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
    
    enum Hand { // 가위, 바위, 보 값에 대한 enum
    	rock, paper, scissors
    }
    
    enum PlayerStatus { // 플레이어의 상태
    	STATUS_WIN, STATUS_LOSE, STATUS_TIE, STATUS_PENDING
    }
    
    struct Player {
    	address payable addr; // 주소
        uint256 playerBetAmount; // 배팅 금액
        Hand hand; // 플레이어가 낸 가위, 바위, 보 값
        PlayerStatus playerStatus; // 사용자의 현 상태
    }
}

게임 구조체 만들기

컨트랙트에는 게임을 진행하는 여러 방(room)이 있으며, 각 방은 모두 같은 형식을 가지고 있다. 방에는 방을 만든 방장 정보, 방에 참여한 참가자 정보, 총 배팅 금액이 있다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
    
    // ...
    
    struct Game {
    	Player originator; // 방장 정보
        Player taker; // 참여자 정보
        uint256 betAmount; // 총 배팅 금액
    }
    
    mapping(uint => Game) rooms; // rooms[0], rooms[1] 형식으로 접근할 수 있다. 각 요소는 game 구조체 형식이다.
    uint roomLen = 0; // rooms의 키 값이다. 방이 생성될 때마다 1씩 올라간다.
}

각 게임은 방장이 방을 만들어둔 상태일 수도 있고, 참여자가 참여하여 게임 결과가 나온 상태일 수도 있고, 게임 결과에 따라 배팅 금액을 분배한 상태일 수도 있다. 또는 게임 중간에 에러가 발생할 수도 있다.

게임의 상태는 위의 네가지 상태만 있어야 하니 enum으로 지정한다.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
	constructor () payable {}
    
    // ...
    
    enum GameStatus { // 게임의 상태
    	STATUS_NOT_STARTED, STATUS_STARTED, STATUS_COMPLETE, STATUS_ERROR
    }
    
    struct Game {
    	Player originator; // 방장 정보
        Player taker; // 참여자 정보
        uint256 betAmount; // 총 배팅 금액
        GameStatus gameStatus; // 게임의 현 상태
    }
}

createRoom - 게임 생성하기

createRoom은 게임을 생성한다. 게임을 생성한 방장은 자신이 낼 가위/바위/보 값을 인자로 보내고, 배팅 금액은 msg.value로 설정한다. 여기서 msg.value는 솔리디티에 정의된 글로벌 변수이므로 함수를 사용할 때 입력받지만, 함수 내에서는 파라미터로 설정할 필요가 없다.

contract RPS {
	// ...
    
    function createRoom (Hand _hand) public payable { // 배팅 금액을 설정하기 때문에 payable 키워드 사용
    // 게임을 만들고 나면, 해당 게임의 방 번호를 반환
    }
}

Game 구조체의 인스턴스를 만든다.

  • betAmount: 아직 방장만 있기 때문에 방장의 배팅 금액을 넣는다.
  • gameStatus: 아직 시작하지 않은 상태이기 때문에 GameStatus.STATUS_NOT_STARTED값을 넣는다.
  • originator: Player 구조체의 인스턴스를 만들어, 방장의 정보를 넣어준다.
  • taker: Player 구조체 형식의 데이터로 초기화되어야 하기 때문에 addr에는 방장의 주소를 hand 에는 Hand.rock으로 할당해준다.

이렇게 만든 Game 인스턴스를 room[roomLen]에 할당한다.

contract RPS {
	// ...
    function createRoom (Hand _hand) public payable returns (uint roomNum) { // 베팅금액을 설정하기 때문에 payable 키워드 사용, 변수 RoomNum의 값을 반환
        rooms[roomLen] = Game({
            betAmount: msg.value,
            gameStatus: GameStatus.STATUS_NOT_STARTED,
            originator: Player({
                hand: _hand,
                addr: payable(msg.sender),
                playerStatus: PlayerStatus.STATUS_PENDING,
                playerBetAmount: msg.value
            }),
            taker: Player({ // will change
                hand: Hand.rock,
                addr: payable(msg.sender),
                playerStatus: PlayerStatus.STATUS_PENDING,
                playerBetAmount: 0
            })
        });
        roomNum = roomLen; // roomNum은 리턴된다. 현재 방 번호를 roomNum에 할당시켜 반환
        roomLen = roomLen+1; // 다음 방 번호를 설정
    }
}

그런데, 방장이 `createRoom`을 실행했을때, 가위/바위/보 값이 아닌 다른 값이 지정될 수 있다. 따라서 올바른 값인지 확인해야 하는 `isValidHand` 함수 제어자를 만들어, `createRoom` 실행 시 확인하도록 한다.
modifier isValidHand (Hand _hand) {
        require((_hand == Hand.rock) || (_hand == Hand.paper) || (_hand == Hand.scissors));
        _;
}

function createRoom (Hand _hand) public payable isValidHand(_hand) returns (uint roomNum) {
	// ...
}

joinRoom - 방에 참가하기

joinRoom은 기존에 만들어진 방에 참가한다.
참가자는 참가할 방 번호와 자신이 낼 가위/바위/보 값을 인자로 보내고, 배팅 금액은 msg.value로 설정한다. 마찬가지로, isValidHand 함수 제어자도 사용한다.

contract RPS {
	// ...
    
    function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
        rooms[roomNum].taker = Player({
            hand: _hand,
            addr: payable(msg.sender),
            playerStatus: PlayerStatus.STATUS_PENDING,
            playerBetAmount: msg.value
        });
        // 참가자가 참여하면서 게임의 배팅 금액이 추가되었기 때문에, Game 인스턴스의 betAmount 역시 변경해준다.
        rooms[roomNum].betAmount = rooms[roomNum].betAmount + msg.value;
    }
}

compareHands() - 게임 결과 업데이트

joinRoom 함수가 끝나는 시점에서, 방장과 참가자가 모두 가위/바위/보 값을 냈기 때문에 게임의 승패를 확인할 수 있다. 게임의 결과에 따라 게임의 상태와 참여자들의 상태를 업데이트 하는 함수 compareHands()를 작성해준다. 게임의 결과는 joinRoom이 완료된 시점에서 확인할 수 있기 때문에 joinRoom 함수의 맨 마지막에 compareHands()함수를 호출해준다. 이 함수는 인자로 게임의 결과를 확인할 방 번호를 받는다.

contract RPS {
	// ...
    
    function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
        rooms[roomNum].taker = Player({
            hand: _hand,
            addr: payable(msg.sender),
            playerStatus: PlayerStatus.STATUS_PENDING,
            playerBetAmount: msg.value
        });
        rooms[roomNum].betAmount = rooms[roomNum].betAmount + msg.value;
        compareHands(); // 게임 결과 업데이트 함수 호출
    }
    
    function compareHands(uint roomNum) private {
    	// ...
    }
}

compareHands를 작성하기 전에, enum Hand를 보면 각 값은 순서에 따라 0부터 숫자가 매겨진다. 방장과 참가자가 가지고 있는 값은 0(rock), 1(paper), 2(scissors) 중 하나이다. 1(paper)은 0(rock)을 이기고, 2(scissors)는 1(paper)를 이기고, 0(rock)은 2(scissors)를 이긴다.

즉, 상대방의 값 x와 나의 값 y에 대해 다음의 조건이 만족하면 자신이 이긴 것이다.

(x + 1) % 3 == y

따라서 방장이 참가자를 이긴 상황을 코드로 작성하면 다음과 같다.

if ((takerHand + 1) % 3 == originatorHand) {
	// originator Win!
}

이제 compareHands 함수를 작성해보자.
먼저, 해당 게임의 방장과 참가자의 가위/바위/보 값은 enum값이기 떄문에 정수형으로 바꿔준다. 또한 게임을 본격적으로 비교하기 때문에, 게임의 상태를 GameStatus.STATUS_STARTED로 변경한다.

function compareHands(uint roomNum) private {
        uint8 originator = uint8(rooms[roomNum].originator.hand);
        uint8 taker = uint8(rooms[roomNum].taker.hand);

        rooms[roomNum].gameStatus = GameStatus.STATUS_STARTED;

        if (taker == originator) { // 비긴 경우
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_TIE;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_TIE;
        }
        else if ((taker + 1) % 3 == originator) { // 방장이 이긴 경우
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_WIN;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_LOSE;
        }
        else if ((originator + 1) % 3 == taker) { // 참가자가 이긴 경우
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_LOSE;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_WIN;
        }
        else { // 그외의 상황에는 게임 상태를 에러로 업데이트 한다.
            rooms[roomNum].gameStatus = GameStatus.STATUS_ERROR;
        }
    }

checkTotalPay - 방마다 배팅 금액 확인하기

각 방마다 배팅이 얼마나 결렸는지 확인할 수 있는 함수이다.
방 번호를 인자로 받아, 해당 방마다 얼마씩 배틍 금액이 걸려있는지 확인할 수 있다. 유저들은 뱉이 금액이 높은 혹은 적은 방을 찾은 후 자유롭게 선택하여 참여할 수 있다. 컨트랙트에 있는 금액을 보기만 하기 위해 솔리디티에 내장되어 있는 view함수를 사용하고, transfer 함수는 다음과 같이 사용하였다.

contract RPS {
	// ...
    
    function checkTotalPay(uint roomNum) public view returns(uint roomNumPay) {
        return rooms[roomNum].betAmount;
    }
}

payout- - 배팅 금액 송금하기

payout 함수는 방 번호를 인자로 받아, 게임 결과에 따라 배팅 금액을 송금하고, 게임을 종료한다. 컨트랙트에 있는 금액을 송금하기 위해서는 솔리디티에 내장되어 있는 transfer 함수를 사용한다. transfer 함수는 다음과 같이 사용할 수 있다.

ADDRESS.transfer(value) // ADDRESS로 value 만큼 송금한다.

가위/바위/보 컨트랙트에서는 비긴 경우에는 자신의 배팅 금액을 돌려받고, 이긴 경우네는 전체 배팅금액을 돌려받는다.

function payout(uint roomNum) public payable {
        if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_TIE && rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_TIE) {
            rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
            rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
        } else {
            if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_WIN) {
                rooms[roomNum].originator.addr.transfer(rooms[roomNum].betAmount);
            } else if (rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_WIN) {
                rooms[roomNum].taker.addr.transfer(rooms[roomNum].betAmount);
            } else {
                rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
                rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
            }
        }
        rooms[roomNum].gameStatus = GameStatus.STATUS_COMPLETE; // 게임이 종료되었으므로 게임 상태 변경
    }

여기서 생각해야 될 점은, payout 함수를 실행하는 주체는 방장 또는 참가자여야 한다는 점이다. 따라서 payout 을 실행하기 전에 해당 함수를 실행하는 주체가 방장 또는 참가자인지 확인하는 함수 isplayer 함수를 만든다.
isplayer 는 방 번호와 함수를 호출한 사용자의 주소를 받는다. 그리고 사용자의 주소가 방장 또는 참가자의 주소와 일치하는지 확인한다.

modifier isPlayer (uint roomNum, address sender) {
        require(sender == rooms[roomNum].originator.addr || sender == rooms[roomNum].taker.addr);
        _;
}

function payout(uint roomNum) public payable isPlayer(roomNum, msg.sender) {...}

전체 코드

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

contract RPS {
    constructor () payable {}

    enum Hand { // 가위, 바위, 보 값에 대한 enum
        rock, paper, scissors
    }

    enum PlayerStatus { // 플레이어의 상태
        STATUS_WIN, STATUS_LOSE, STATUS_TIE, STATUS_PENDING
    }

    enum GameStatus { // 게임의 상태
        STATUS_NOT_STARTED, STATUS_STARTED, STATUS_COMPLETE, STATUS_ERROR
    }

    struct Player { // player structure
        address payable addr; // 주소
        uint256 playerBetAmount; // 베팅 금액
        Hand hand;
        PlayerStatus playerStatus; // 사용자의 현 상태
    }

    struct Game {
        Player originator; // 방장 정보
        Player taker; // 참여자 정보
        uint256 betAmount; // 총 베팅 금액
        GameStatus gameStatus; // 게임의 현 상태
    }

    mapping(uint => Game) rooms; // rooms[0], rooms[1] 형식으로 접근할 수 있으며, 각 요소는 Game 구조체 형식
    uint roomLen = 0; // rooms의 키 값, 방이 생성될 때마다 1씩 올라간다.

    modifier isValidHand (Hand _hand) {
        require((_hand == Hand.rock) || (_hand == Hand.paper) || (_hand == Hand.scissors));
        _;
    }

    modifier isPlayer (uint roomNum, address sender) {
        require(sender == rooms[roomNum].originator.addr || sender == rooms[roomNum].taker.addr);
        _;
    }

    function createRoom (Hand _hand) public payable isValidHand(_hand) returns (uint roomNum) { // 베팅금액을 설정하기 때문에 payable 키워드 사용, 변수 RoomNum의 값을 반환
        rooms[roomLen] = Game({
            betAmount: msg.value,
            gameStatus: GameStatus.STATUS_NOT_STARTED,
            originator: Player({
                hand: _hand,
                addr: payable(msg.sender),
                playerStatus: PlayerStatus.STATUS_PENDING,
                playerBetAmount: msg.value
            }),
            taker: Player({ // will change
                hand: Hand.rock,
                addr: payable(msg.sender),
                playerStatus: PlayerStatus.STATUS_PENDING,
                playerBetAmount: 0
            })
        });
        roomNum = roomLen; // roomNum은 리턴된다. 현재 방 번호를 roomNum에 할당시켜 반환
        roomLen = roomLen+1; // 다음 방 번호를 설정
    }

    function joinRoom(uint roomNum, Hand _hand) public payable isValidHand( _hand) {
        rooms[roomNum].taker = Player({
            hand: _hand,
            addr: payable(msg.sender),
            playerStatus: PlayerStatus.STATUS_PENDING,
            playerBetAmount: msg.value
        });
        rooms[roomNum].betAmount = rooms[roomNum].betAmount + msg.value;
        compareHands(roomNum); // 게임 결과 업데이트 함수 호출
    }

    function checkTotalPay(uint roomNum) public view returns(uint roomNumPay) {
        return rooms[roomNum].betAmount;
    }

    function payout(uint roomNum) public payable isPlayer(roomNum, msg.sender) {
        if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_TIE && rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_TIE) {
            rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
            rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
        } else {
            if (rooms[roomNum].originator.playerStatus == PlayerStatus.STATUS_WIN) {
                rooms[roomNum].originator.addr.transfer(rooms[roomNum].betAmount);
            } else if (rooms[roomNum].taker.playerStatus == PlayerStatus.STATUS_WIN) {
                rooms[roomNum].taker.addr.transfer(rooms[roomNum].betAmount);
            } else {
                rooms[roomNum].originator.addr.transfer(rooms[roomNum].originator.playerBetAmount);
                rooms[roomNum].taker.addr.transfer(rooms[roomNum].taker.playerBetAmount);
            }
        }
        rooms[roomNum].gameStatus = GameStatus.STATUS_COMPLETE; // 게임이 종료되었으므로 게임 상태 변경
    }

    function compareHands(uint roomNum) private {
        uint8 originator = uint8(rooms[roomNum].originator.hand);
        uint8 taker = uint8(rooms[roomNum].taker.hand);

        rooms[roomNum].gameStatus = GameStatus.STATUS_STARTED;

        if (taker == originator) { // 비긴 경우
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_TIE;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_TIE;
        }
        else if ((taker + 1) % 3 == originator) { // 방장이 이긴 경우
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_WIN;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_LOSE;
        }
        else if ((originator + 1) % 3 == taker) { // 참가자가 이긴 경우
            rooms[roomNum].originator.playerStatus = PlayerStatus.STATUS_LOSE;
            rooms[roomNum].taker.playerStatus = PlayerStatus.STATUS_WIN;
        }
        else { // 그외의 상황에는 게임 상태를 에러로 업데이트 한다.
            rooms[roomNum].gameStatus = GameStatus.STATUS_ERROR;
        }
    }
}
profile
Blockchain Developer

0개의 댓글