From 0299ba668932201521c7892fc9341cea2c52b83f Mon Sep 17 00:00:00 2001 From: Tony Robinson Date: Fri, 8 Sep 2023 15:17:25 +0100 Subject: [PATCH] Adding a Four In A Row app (08Sep23) This replaces #446 which was FUBAR Signed-off-by: Tony Robinson --- README.rst | 4 + apps/four_in_a_row.py | 205 ++++++++++++++++++++++++++++++ docs/apps.rst | 2 + res/screenshots/FourInARowApp.png | Bin 0 -> 4779 bytes 4 files changed, 211 insertions(+) create mode 100644 apps/four_in_a_row.py create mode 100644 res/screenshots/FourInARowApp.png diff --git a/README.rst b/README.rst index 82cfb67..ac2fde7 100644 --- a/README.rst +++ b/README.rst @@ -191,6 +191,10 @@ Games: :alt: 15 Puzzle running in the wasp-os simulator :width: 179 +.. image:: res/screenshots/FourInARowApp.png + :alt: Four In A Row running in the wasp-os simulator + :width: 179 + Time management apps: .. image:: res/screenshots/AlarmApp.png diff --git a/apps/four_in_a_row.py b/apps/four_in_a_row.py new file mode 100644 index 0000000..f3d0d10 --- /dev/null +++ b/apps/four_in_a_row.py @@ -0,0 +1,205 @@ +# SPDX-License-Identifier: LGPL-3.0-or-later +# Copyright (C) 2023 Tony Robinson + +"""Four in a Row +~~~~~~~~~~~~~~~~ + +This is the classic two player board game called Four In A Row or Connect4. You play against the computer. + +.. figure:: res/screenshots/FourInARowApp.png + :width: 179 + + Screenshot of Four In A Row + +There is in intro/menu screen which has very brief instructions, allows you to set the opponent level and gives some stats on the number of games you have won. Touching the screen sets the level from 0 to 6 which corresponds to the number of lookahead plies. Swiping down enters the main game. On your turn a red square counter will appear on the top row. Touch the screen to move to the desired column, optinally touch again if you don't like your choice then swipe down to commit to playing that column. The computer will reply with a yellow counter after a delay (dependent on level). Your aim is to get four in a row before the computer does. At end of game swipe down to return to the intro/menu screen. + +This app is powered by a compact version of the Alpha Beta pruning algorithm. For technical details see https://en.wikipedia.org/wiki/Connect_Four and kaggle.com/competitions/connectx/. There isn't space for a transposition table in RAM, so MTDf isn't implmented, nevertheless it can play a challenging game. + +""" + +import wasp, array +from micropython import const + +# parameters of the 'Four in a Row' game +_NROW = const(6) +_NCOLUMN = const(7) +# assert _NCOLUMN == _NROW + 1 # need square, top row is where counters are placed +_NMIDDLE = const((_NCOLUMN - 1) // 2) +_NBOARD = const(_NROW * _NCOLUMN) + +# colours +_BLUE = const(0x0010) # Blue: half way to full blue +_BLACK = const(0x0000) # Black +_cPLAY = const(0xF800) # Red: colour for human PLAYer - becuase I like playing red +_cCOMP = const(0xFFE0) # Yellow: colour for COMPputer pieces + +# basics of the display +_NPIXEL = const(240) +_NCELL = const(_NPIXEL // _NCOLUMN) +_NBORDER = const((_NPIXEL - _NCELL * _NCOLUMN) // 2) +_NPAD = const(2) + +# states of game +_INTRO, _PLAY, _GAMEOVER = const(0), const(1), const(2) +_NLEVEL = const(7) + +def _gameOver(bitmap): + for s in [1, _NCOLUMN, _NCOLUMN+2, _NCOLUMN+1]: + b = bitmap & (bitmap >> s) + if b & (b >> 2 * s): + return True + return False + +def _bitmapGet(bitmap, x, y): + return (bitmap >> (y * (_NCOLUMN + 1) + x)) & 1 + +def _bitmapSet(bitmap, x, y): + return bitmap | 1 << (y * (_NCOLUMN + 1) + x) + +_searchOrder = bytearray(sorted(range(_NCOLUMN), key=lambda x:abs(x - _NMIDDLE))) + +def _swapmin(bitNext, bitLast, low, depth, lob, upb): + sumLow = sum(low) + + # list all the legal moves + legal = [ n for n in _searchOrder if low[n] != _NROW ] + + # see if we can win + for x in legal: + if _gameOver(_bitmapSet(bitNext, x, low[x])): + return (_NBOARD - sumLow + 1) // 2, x + + # easy wins done, quit now if at search depth or end of board + if depth == 0 or sumLow == _NBOARD: + return 0, -1 # -1 is not right but DRAW will catch it + + # if can't win then see if we have to block + for x in legal: + if _gameOver(_bitmapSet(bitLast, x, low[x])): + legal = [ x ] + break + + bestv = _NBOARD + for x in legal: + lowp = bytearray(low) # bytearray used as copy.copy() + lowp[x] += 1 + v, _ = _swapmin(bitLast, _bitmapSet(bitNext, x, low[x]), lowp, depth-1, -upb, -lob) + if v < bestv: + bestv, bestx = v, x + upb = min(upb, bestv) + if bestv <= lob: + break + + return -bestv, bestx + +def rndColumn(): + return int(wasp.watch.rtc.uptime) % _NCOLUMN # time used as RNG to save space + +# class FourinaRowApp(): # names are different between firmware and /flash +class FourInARowApp(): + NAME = '4 ina row' + + def __init__(self): + self.screen = _INTRO + self.level = wasp.widgets.Slider(_NLEVEL, x=10, y=150, color=_cPLAY) + self.nwin = array.array('H', [0] * _NLEVEL) + self.ngame = array.array('H', [0] * _NLEVEL) + + def _showStats(self): + nwin = self.nwin[self.level.value] + ngame = self.ngame[self.level.value] + pc = int(100 * nwin / ngame + 0.5) if ngame > 0 else 0 + wasp.watch.drawable.string('level %d won %d%%' % (self.level.value, pc), 0, 200, width=_NPIXEL) + + def _place(self, x, y, colour): + wasp.watch.drawable.fill(x=_NBORDER + x*_NCELL + _NPAD, y=_NBORDER + (_NROW-y)*_NCELL + _NPAD, w=_NCELL - 2*_NPAD, h=_NCELL - 2*_NPAD, bg=colour) + + def foreground(self): + draw = wasp.watch.drawable + + # draw board from state + draw.fill() + if self.screen == _INTRO: + draw.string('FOUR IN A ROW', 0, 30, width=_NPIXEL) + draw.string('Touch sets column', 0, 80, width=_NPIXEL) + draw.string('swipe down to play', 0, 110, width=_NPIXEL) + self.level.draw() + self._showStats() + else: + draw.fill(x=0, y=_NBORDER+_NCELL, w=_NPIXEL, h=_NPIXEL-_NCELL, bg=_BLUE) + for x in range(_NCOLUMN): + for y in range(_NROW): + if _bitmapGet(self.bPLAY, x, y): + self._place(x, y, _cPLAY) + elif _bitmapGet(self.bCOMP, x, y): + self._place(x, y, _cCOMP) + self._place(self.x, _NROW, _cPLAY) + + wasp.system.request_event(wasp.EventMask.TOUCH | wasp.EventMask.SWIPE_UPDOWN) + + # touch to set the drop column + def touch(self, event): + if self.screen == _INTRO: + self.level.touch(event) + self.level.update() + self._showStats() + elif self.screen == _PLAY: + self._place(self.x, _NROW, _BLACK) + self.x = min(max((event[1] - _NBORDER) // _NCELL, 0), _NCOLUMN-1) + self._place(self.x, _NROW, _cPLAY) + + def _drop(self, x, colour): + if self.low[x] == _NROW: + wasp.watch.vibrator.pulse() # can't move here! + else: + y = self.low[x] + for i in range(_NROW, y, -1): + self._place(x, i-1, colour) + self._place(x, i, _BLUE if i != _NROW else _BLACK) + wasp.watch.time.sleep(0.1) + if colour == _cPLAY: + self.bPLAY = _bitmapSet(self.bPLAY, x, y) + else: + self.bCOMP = _bitmapSet(self.bCOMP, x, y) + self.low[x] += 1 + + # swipe down to drop, swipe left/right to exit + def swipe(self, event): + if event[0] == wasp.EventType.DOWN: + if self.screen == _INTRO: + self.x = _NMIDDLE + self.bPLAY = 0 + self.bCOMP = 0 + self.low = bytearray(_NCOLUMN) + self.screen = _PLAY + self.foreground() + if self.ngame[self.level.value] % 2: + self._drop(rndColumn(), _cCOMP) + elif self.screen == _PLAY and self.low[self.x] != _NROW: + # play human PLAYer + self._drop(self.x, _cPLAY) + if _gameOver(self.bPLAY): + wasp.watch.drawable.string('YOU WIN!', 0, 0, width=_NPIXEL) + self.nwin[self.level.value] += 1 + self.screen = _GAMEOVER + return + + if sum(self.low) < 3: + x = rndColumn() + else: + depth = self.level.value + 1 + if depth == 2 and sum(self.low) < 8: + depth += 1 + v, x = _swapmin(self.bCOMP, self.bPLAY, self.low, depth, -_NBOARD, _NBOARD) + self._drop(x, _cCOMP) + + if _gameOver(self.bCOMP): + wasp.watch.drawable.string('YOU LOSE!', 0, 0, width=_NPIXEL) + self.screen = _GAMEOVER + return + + self._place(self.x, _NROW, _cPLAY) + elif self.screen == _GAMEOVER: + self.ngame[self.level.value] += 1 + self.screen = _INTRO + self.foreground() diff --git a/docs/apps.rst b/docs/apps.rst index 8efb029..d2a8302 100644 --- a/docs/apps.rst +++ b/docs/apps.rst @@ -87,3 +87,5 @@ Games .. automodule:: puzzle15 .. automodule:: snake + +.. automodule:: four_in_a_row diff --git a/res/screenshots/FourInARowApp.png b/res/screenshots/FourInARowApp.png new file mode 100644 index 0000000000000000000000000000000000000000..2df0a5dcf0e407764d8bbbb4c9cc677dad5921c8 GIT binary patch literal 4779 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 F{SWr&XK(-j literal 0 HcmV?d00001