;;; -*- Mode: Emacs-Lisp -*- ;;; File: sokoban.el ;;; Description: sokoban game: Push packets into goal positions ;;; throughout 50 game screens. ;;; Author: Boaz Ben-Zvi <boaz@lcs.mit.edu> ;;; Idea taken from: X11 version by Kevin Solie <kevins@ms.uky.edu> ;;; Last Modified: Jan. 9, 1992 ;;; Version: 1.3 ;; Copyright (C) 1992 Boaz Ben-Zvi ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation; either version 1, or (at your option) ;; any later version. ;; This program is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program; if not, write to the Free Software ;; Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. ;;;---------------------------------------------------------------------- ;; HOW TO PLAY: ;; ;; Start the game and move the player [] using control-N,P,B,F keys or ;; (arrow keys or) h,j,k,l (like vi). Use the player to push packets >< ;; into goal positions .. . For example, starting from the left: ;; ;; ############### ############### ############### ############### ;; ##[] ## ..## ## ## ..## ## ## ..## ## ## )(## ;; ## ## ## ## ## ## ## ## ## ## ## []## ;; ## >< ## ##[]>< ## ## []><## ## ## ;; ## ## ## ## ## ## ## ## ## ## ## ## ;; ############### ############### ############### ############### ;; HOW TO INSTALL: ;; ;; The game comes with 50 screen files (all named "screen.<number>"). ;; You need to put them all in some directory and set the value of ;; sokoban-screen-file-dir to the correct directory. ;;;---------------------------------------------------------------------- ;;; ;;; History: ;;; Version 1.0 -- Dec. 30, 1991 ;;; Version 1.1 -- Jan. 6, 1992 : Variable size squares, restart game, ;;; report moves/pushes, bugs fixed. ;;; Version 1.2 -- Jan. 7, 1992 : mode hook added, bugs fixed. ;;; Version 1.3 -- Jan. 9, 1992 : style improved. ;;; ;;; USER CONFIGURABLE VARIABLES ;;; (defvar sokoban-screen-file-dir "~/emacs/lib/sokoban-screens" "*Directory where the screen files reside.") (defvar sokoban-save-file "~/.sokoban" "*File to save current game") (defvar sokoban-score-file "~/.sokoban.score" "*File to save current score") ;; Shape of game squares. ;; You can change them to have a different number of characters per square ;; (e.g., 1 character), only make sure that they all are of the same size ! ;; Be careful if changing to other characters: the game file uses different ;; char-codes which are replaced at game load time (with replace-string). (defvar sokoban-wall-square "##" "*Pattern for wall square") (defvar sokoban-gold-square "><" "*Pattern for gold packet square") (defvar sokoban-goal-square ".." "*Pattern for goal square") (defvar sokoban-player-square "[]" "*Pattern for player square") (defvar sokoban-gold-on-goal-square ")(" "*Pattern for gold on goal square") (defvar sokoban-player-on-goal-square "{}" "*Pattern: Player on goal square") (defvar sokoban-empty-square " " "*Pattern for an empty square") (defvar sokoban-size (length sokoban-empty-square) "Size of each square (in characters)") (defvar sokoban-beep-error t "*Iff non-nil, sound a beep on a move error") ;;; ;;; INTERNAL VARIABLES ;;; (defvar sokoban-mode-hook nil "Called after sokoban-mode is set") (defvar sokoban-level 1 "Current level (in range 1..sokoban-max-level)") (defvar sokoban-initial-level 0 "Initial level") (defvar sokoban-gold-num 0 "Total number of gold packets in current game") (defvar sokoban-goals 0 "Number of golds currently on goal squares. \ A game is finished when this equals sokoban-gold-num") (defvar sokoban-buffer "*sokoban*") (defvar sokoban-help-buffer "*sokoban-help*") (defvar sokoban-undo-stack nil "Stack of previous moves to facilitate UNDO") (defvar sokoban-moves 0 "Number of moves in current game") (defvar sokoban-pushes 0 "Number of pushes in current game") (defconst sokoban-file-header-regexp "Level: [0-9]+[ \t]+Packets: [0-9]+[ \t]+Goals: [0-9]+[ \t]*$" "Regexp describing header (first line) of game file") (defconst sokoban-score-file-regexp "Level: [0-9]+[ \t]*" "Regexp describing format of score file") (defconst sokoban-mode-map nil) (defconst sokoban-max-level 50 "Maximal screen number available") (defvar sokoban-player-pos nil "Position of player (should be == (point))") (defvar sokoban-path-count (make-vector 10000 0) "Vector that counts how many times each point is visited.") ;;; ;;; SOKOBAN FUNCTIONS ;;; (defun sokoban (level) "Play sokoban. Type ? for help. Optional argument specifies level between 1 and sokoban-max-level, if zero then start from a saved game." (interactive "P") (cond ((null level) (sokoban-load-game (sokoban-screen-file (sokoban-get-score)))) ((eq level 0) (sokoban-load-game sokoban-save-file)) ((numberp level) (sokoban-load-game (sokoban-screen-file level))) (t (error "Bad argument given: %s" level)) ) (global-unset-key "") (global-unset-key " ") (global-unset-key " ") ) (defun sokoban-screen-file (num) "Return name of screen file for level NUM." (if (or (> num sokoban-max-level) (< num 1)) (error "Level %d does not exist! Pick one between 1-%d." num sokoban-max-level)) (concat sokoban-screen-file-dir "/screen." (int-to-string num))) (defun sokoban-init-map () "Initialize sokoban-mode-map." (setq sokoban-mode-map (make-keymap)) (suppress-keymap sokoban-mode-map 'no-digits) (aset (cadr sokoban-mode-map) 2 'sokoban-move-left) ;; (aset (cadr sokoban-mode-map) 6 'sokoban-move-right) ;; (aset (cadr sokoban-mode-map) 16 'sokoban-move-up) ;; (aset (cadr sokoban-mode-map) 14 'sokoban-move-down) ;; (aset (cadr sokoban-mode-map) 104 'sokoban-move-left) ;; h (aset (cadr sokoban-mode-map) 108 'sokoban-move-right) ;; l (aset (cadr sokoban-mode-map) 107 'sokoban-move-up) ;; k (aset (cadr sokoban-mode-map) 106 'sokoban-move-down) ;; j (aset (cadr sokoban-mode-map) 101 'sokoban-exit) ;; e (aset (cadr sokoban-mode-map) 63 'sokoban-help) ;; ? (aset (cadr sokoban-mode-map) 109 'sokoban-report-moves) ;; m (aset (cadr sokoban-mode-map) 113 'sokoban-quit) ;; q (aset (cadr sokoban-mode-map) 114 'sokoban-restart) ;; r (aset (cadr sokoban-mode-map) 115 'sokoban-save-game) ;; s (aset (cadr sokoban-mode-map) 117 'sokoban-undo-move) ;; u ) (defun sokoban-mode () "Mode for playing SOKOBAN. Press ? for help." (kill-all-local-variables) (if (null sokoban-mode-map) (sokoban-init-map)) (use-local-map sokoban-mode-map) (setq mode-name "sokoban") (setq major-mode 'sokoban-mode) (setq buffer-read-only t) (sokoban-set-mode-line) (run-hooks 'sokoban-mode-hook) ) (defun sokoban-set-mode-line() "Update and redisplay the sokoban mode line." (setq mode-line-format (concat " * S O K O B A N * " "Level: " (int-to-string sokoban-level) " Packets: " (int-to-string sokoban-gold-num) " Goals: " (int-to-string sokoban-goals) " (type ? for help)")) ;;; force redisplay of mode-line (save-excursion (set-buffer (other-buffer))) (set-buffer-modified-p (buffer-modified-p)) (sit-for 0)) ;;; ;;; Load game file and initiate board ;;; (defun sokoban-replace-string (from to) "Replace string FROM with TO in rest of current buffer." (save-excursion (while (search-forward from nil t) (replace-match to)))) (defun sokoban-load-game (filename) "Loads a game saved in FILENAME. Sets parameters from the header." (let ((buffer-read-only nil)) ; allow updates to buffer (if (file-readable-p (expand-file-name filename)) nil (error "Can not read file %s" filename)) (switch-to-buffer (get-buffer-create sokoban-buffer)) (erase-buffer) (insert-file-contents (expand-file-name filename)) ;; ----- READ HEADER (goto-char 1) (cond ((not (looking-at sokoban-file-header-regexp)) (kill-buffer (current-buffer)) (error "File %s: Bad format!" filename))) (re-search-forward "[0-9]+") (setq sokoban-level (string-to-int (buffer-substring (match-beginning 0) (point)))) (if (= sokoban-initial-level 0) (setq sokoban-initial-level sokoban-level)) ;; first time only (re-search-forward "[0-9]+") (setq sokoban-gold-num (string-to-int (buffer-substring (match-beginning 0) (point)))) (re-search-forward "[0-9]+") (setq sokoban-goals (string-to-int (buffer-substring (match-beginning 0) (point)))) (re-search-forward "\n") (delete-region 1 (point)) ;; ---- CONVERT FILE FORMAT TO GAME FORMAT ( must be (= 1 (point)) ! ) (sokoban-replace-string "#" sokoban-wall-square) (sokoban-replace-string " " sokoban-empty-square) (sokoban-replace-string "$" sokoban-gold-square) (sokoban-replace-string "@" sokoban-player-square) (sokoban-replace-string "*" sokoban-gold-on-goal-square) (sokoban-replace-string "+" sokoban-player-on-goal-square) (sokoban-replace-string "." sokoban-goal-square) ;; ( must be here: (= 1 (point)) ! ) (cond ((re-search-forward (regexp-quote sokoban-player-square) nil t) (setq sokoban-player-pos (1- (point)))) ((re-search-forward (regexp-quote sokoban-player-on-goal-square) nil t) (setq sokoban-player-pos (1- (point)))) (t (kill-buffer (current-buffer)) (error "Did not find player in game in file %s" filename))) (goto-char sokoban-player-pos) (sokoban-mode) (setq sokoban-undo-stack '()) ;; reset undo stack (setq sokoban-moves 0) (setq sokoban-pushes 0) (message "Sokoban: Loaded game screen number %d" sokoban-level) )) ;;; ;;; All the "move" commands bellow assume that the game board is correct !!! ;;; (i.e. they do not check for possible errors) ;;; (defun sokoban-move-left() "Move sokoban player one square to the left (if possible)." (interactive) (goto-char sokoban-player-pos) ;; in case cursor not on player (let ((next-point (- sokoban-player-pos sokoban-size)) (push-to-point (- sokoban-player-pos (* 2 sokoban-size)))) (if (> 1 push-to-point) (error "") ;; only on badly formatted boards (sokoban-move next-point push-to-point)))) (defun sokoban-move-right() "Move sokoban player one square to the right (if possible)." (interactive) (goto-char sokoban-player-pos) ;; in case cursor not on player (let ((next-point (+ sokoban-player-pos sokoban-size)) (push-to-point (+ sokoban-player-pos (* 2 sokoban-size)))) (if (> push-to-point (point-max)) (error "") ; on badly formatted board (sokoban-move next-point push-to-point)))) (defun sokoban-move-up() "Move sokoban player one square up (if possible)." (interactive) (goto-char sokoban-player-pos) ;; in case cursor not on player (sokoban-move-vertical -1)) (defun sokoban-move-down() "Move sokoban player one square down (if possible)." (interactive) (goto-char sokoban-player-pos) ;; in case cursor not on player (sokoban-move-vertical 1)) (defun sokoban-move-vertical (direction) "Do move-up for DIRECTION value of -1, down for 1." (let ((temporary-goal-column (current-column)) next-point push-to-point) (save-excursion (forward-line direction) (move-to-column temporary-goal-column) ;; bellow -- error on badly formatted boards (if (< (current-column) temporary-goal-column) (error "")) (setq next-point (point)) (forward-line direction) (move-to-column temporary-goal-column) ;; bellow -- error when push-to-line is too short (setq push-to-point ;; (if (= (current-column) temporary-goal-column) (point) next-point))) ;; cause an error !! (if (= next-point push-to-point) ;; last/first line (if sokoban-beep-error (error "Can not move through walls!") (message "Can not move through walls!"))) (sokoban-move next-point push-to-point) )) (defun sokoban-set-square (square what goal) "Set SQUARE to WHAT, check for the case when SQUARE is GOAL." (let ((buffer-read-only nil)) (save-excursion (goto-char (- square (1- sokoban-size))) (delete-char sokoban-size) (cond ((eq what 'player) (if goal (insert sokoban-player-on-goal-square) (insert sokoban-player-square))) ((eq what 'gold) (if goal (insert sokoban-gold-on-goal-square) (insert sokoban-gold-square))) ((eq what 'empty) (if goal (insert sokoban-goal-square) (insert sokoban-empty-square))) (t (error "sokoban-set-square received unknown symbol"))) ))) (defun sokoban-get-square (point) "Returns the string in the square pointed to by POINT." (buffer-substring (- point (1- sokoban-size)) (1+ point))) (defun sokoban-move (next-point push-to-point) "Perform a sokoban move. Use three points: sokoban-player-pos, the neighboring NEXT-POINT and its following neighbor PUSH-TO-POINT to perform a sokoban move, regardless of the move direction." (let ((player (sokoban-get-square sokoban-player-pos)) (next (sokoban-get-square next-point)) (push-to (sokoban-get-square push-to-point)) player-goal next-goal push-to-goal ;; are these GOAL squares ?? (pushed nil) (can-move nil) (err "")) (setq player-goal (string-equal sokoban-player-on-goal-square player)) (setq next-goal (or (string-equal sokoban-goal-square next) (string-equal sokoban-gold-on-goal-square next))) (setq push-to-goal (string-equal sokoban-goal-square push-to)) (cond ((or (string-equal next sokoban-empty-square) ; no need to push (string-equal next sokoban-goal-square)) (setq can-move t)) ((and (or (string-equal next sokoban-gold-square) ;; gold to push (string-equal next sokoban-gold-on-goal-square)) (or (string-equal push-to sokoban-empty-square) ;; is clear? (string-equal push-to sokoban-goal-square))) (setq can-move t pushed t)) (t )) (cond (can-move (sokoban-set-square sokoban-player-pos 'empty player-goal) (sokoban-set-square next-point 'player next-goal) (cond (pushed (sokoban-set-square push-to-point 'gold push-to-goal) ;;; --- UPDATE GOALS COUNT (if needed) (if next-goal (setq sokoban-goals (1- sokoban-goals))) (if push-to-goal (setq sokoban-goals (1+ sokoban-goals))) (sokoban-set-mode-line))) ;;; update mode line ;;; ---- LOAD NEXT SCREEN (if it is time) (if (= sokoban-goals sokoban-gold-num) (if (not (= sokoban-level sokoban-max-level)) (sokoban-load-game (sokoban-screen-file (1+ sokoban-level))) (kill-buffer (current-buffer)) (error "Sokoban game is over.")) ;;; else ---- PUSH INTO UNDO STACK (setq sokoban-undo-stack (cons (list (cons sokoban-player-pos player-goal) (cons next-point next-goal) (if (not pushed) nil (cons push-to-point push-to-goal))) sokoban-undo-stack)) (setq sokoban-moves (1+ sokoban-moves)) (if pushed (setq sokoban-pushes (1+ sokoban-pushes))) (goto-char next-point) (setq sokoban-player-pos next-point) (aset sokoban-path-count (dot) (+ (aref sokoban-path-count next-point) 1)))) (t (if (string-equal next sokoban-wall-square) (setq err "Can not move through walls!") (if (string-equal push-to sokoban-wall-square) (setq err "Can not push through walls!") (setq err "Can not push more than one packet!"))) (if sokoban-beep-error (error err) (message err)))) )) (defun sokoban-undo-move (num) "Undo last move. With positive NUM undo last NUM moves." (interactive "p") (while (< 0 num) (if (null sokoban-undo-stack) (error "Undo stack is empty !")) (let* ((last-pos sokoban-player-pos) (last-move (car sokoban-undo-stack)) ;; Each of the following three is a pair: < square (= point) ;; . goal (= t iff goal square) > (player (car last-move)) (next (car (cdr last-move))) (pushed (car (cdr (cdr last-move))))) (sokoban-set-square (car player) 'player (cdr player)) (if (not pushed) (sokoban-set-square (car next) 'empty (cdr next)) (sokoban-set-square (car next) 'gold (cdr next)) (sokoban-set-square (car pushed) 'empty (cdr pushed)) (cond ((and (cdr pushed) (not (cdr next))) (setq sokoban-goals (1- sokoban-goals)) (sokoban-set-mode-line)) ((and (not (cdr pushed)) (cdr next)) (setq sokoban-goals (1+ sokoban-goals)) (sokoban-set-mode-line)) )) (aset sokoban-path-count last-pos (- (aref sokoban-path-count last-pos) 1)) (goto-char (car player)) (setq sokoban-player-pos (car player)) (setq sokoban-undo-stack (cdr sokoban-undo-stack)) (setq sokoban-moves (1- sokoban-moves)) (if pushed (setq sokoban-pushes (1- sokoban-pushes)))) (setq num (1- num)))) (defun sokoban-help () "Describe the SOKOBAN game." (interactive) (switch-to-buffer (get-buffer-create sokoban-help-buffer)) (with-output-to-temp-buffer sokoban-help-buffer (insert "SOKOBAN:\n" " The problem in this game is to push packets (each looks " "like " sokoban-gold-square " ) into\n" "the goal positions (each looks like " sokoban-goal-square " ) using the player " "( " sokoban-player-square " ).\n" " A player can only push one packet at a time. " "Neither player nor packet can \ngo through walls ( " sokoban-wall-square " ), and both change form " "a little when positioned on a\ngoal position (to " sokoban-player-on-goal-square " and " sokoban-gold-on-goal-square " ).\n" " To move the player use the Control-B,F,N,P (or arrow) keys " "or h,j,k,l \n( vi style ). Other useful keys:\n\n" "\tq: quit (current level is kept in sokoban-score-file)\n" "\ts: save current game (in sokoban-save-file)\n" "\te: exit game \n" "\tu: undo last move (with argument: undo several moves)\n" "\tr: restart current level\n" "\tm: report number of moves and pushes so far\n" "\nSTARTING THE GAME: \n" " Calling \"M-x sokoban\" without an argument starts " "the game at your current \n" "level (taken from the score file). With a numerical " "argument: start at that\n" "argument level (between 1 and " (int-to-string sokoban-max-level) "). With 0 (zero): " "start from the save file. \n") ) ) (defun sokoban-get-score () "Gets level from local score file." (let ((full-name (expand-file-name sokoban-score-file)) (temp-buff (make-temp-name "sokoban")) temp) (if (not (file-exists-p full-name)) 1 ; start at level 1 (if (not (file-readable-p full-name)) (error "Can not read score file: %s" sokoban-score-file)) (set-buffer (get-buffer-create temp-buff)) (insert-file-contents full-name) (goto-char 1) (if (not (looking-at sokoban-score-file-regexp)) (error "Score file %s: Bad format!" sokoban-score-file)) (re-search-forward "[0-9]+") (setq temp (string-to-int (buffer-substring (match-beginning 0) (match-end 0)))) (kill-buffer temp-buff) temp) )) (defun sokoban-exit () "Exit game, save nothing." (interactive) (if (y-or-n-p "Really exit? ") (kill-buffer (current-buffer))) (message "")) (defun sokoban-quit () "Quit game, keeping current level in score file." (interactive) (let ((full-name (expand-file-name sokoban-score-file)) (continue t) (question "Want to quit? ")) (if (= sokoban-initial-level sokoban-level) ;; dont save (setq continue nil) (if (file-writable-p full-name) nil ;; trouble writing ? (setq continue nil) (setq question (concat (format "Can not write score file: %s . So: " sokoban-score-file) question)))) (cond ((y-or-n-p question) (message "") ;; erase question (cond (continue (find-file full-name) (erase-buffer) (insert "Level: " (int-to-string sokoban-level) " \n") (save-buffer 0) (kill-buffer (current-buffer)) (set-buffer sokoban-buffer))) (kill-buffer (current-buffer))) (t (message "Quiting aborted."))))) (defun sokoban-save-game () "Save current game as is (without undo information)." (interactive) (let ((full-name (expand-file-name sokoban-save-file)) start) (if (file-writable-p full-name) nil ;; trouble writing ? (error "Save file %s: can not write!" sokoban-save-file)) (find-file full-name) (delete-region (point-min) (point-max)) (insert-buffer sokoban-buffer) (goto-char 1) (sokoban-replace-string sokoban-wall-square "#") (sokoban-replace-string sokoban-empty-square " ") (sokoban-replace-string sokoban-gold-square "$") (sokoban-replace-string sokoban-player-square "@") (sokoban-replace-string sokoban-gold-on-goal-square "*") (sokoban-replace-string sokoban-player-on-goal-square "+") (sokoban-replace-string sokoban-goal-square ".") (insert "Level: " (int-to-string sokoban-level) " Packets: " (int-to-string sokoban-gold-num) " Goals: " (int-to-string sokoban-goals) " \n") (save-buffer 0) (kill-buffer (current-buffer)) (set-buffer sokoban-buffer) (message "Current game saved."))) (defun sokoban-report-moves () "Report number of moves and pushes so far in this game." (interactive) (message "So far: %d moves and %d pushes" sokoban-moves sokoban-pushes)) (defun sokoban-restart () "Restart game at current level." (interactive) (if (y-or-n-p "Restart this game? ") (sokoban-load-game (sokoban-screen-file sokoban-level)) (message "")))