We aim to help English speakers improve their command of Chinese through a gamified educational app, making their learning experience fun and engaging.
Our app includes various features such as weekly leaderboards and achievements that encourage the users to practise consistently, which is crucial in maintaining language proficiency.
With over 1.2 billion Chinese speakers in the world and the emergence of China as a global economic superpower, bringing along a huge potential market comprising 18% of the world population, mastering the Chinese language has become more vital than ever.
In Singapore, our founding father has long recognized the importance of the Chinese language and hence enforced bilingualism in our education policy. However, despite government initiatives such as the Speak Mandarin Campaign, Chinese language proficiency among Singaporean Chinese continue to deteriorate rapidly. Apart from the pragmatic economic concerns, the lack of proficiency in one’s own mother tongue also erodes one’s cultural identity, dignity and self-confidence.
As such, it is imperative for new strategies to be adopted to improve Chinese proficiency among Singaporean Chinese. Furthermore, there has been a growing interest in the Chinese language among non-Chinese ethnic groups.
Our app therefore seeks to make the arduous journey of learning the Chinese language fun and enjoyable.
While our focus is on Chinese, we aim to make the project architecture flexible so that support for other languages can be easily implemented in the future.
While there are many existing applications for learning Chinese, they are not fun and engaging in general. For instance, an app named “Learn Chinese the Fun Way” comprise mainly dictionary definitions and penmanship exercises with barely any elements of fun. Another app named “Chinese Idioms Game” merely provides a picture hint and scrambled words for the user to guess the idiom. Lastly, an app named “ChineseSkill” consists of multiple choice questions requiring the user to pick the correct picture matching the given word. As can be seen, these apps are dull and boring in nature despite claiming to be game-based.
The gamification of learning has long be proven to be beneficial in various aspects, such as providing the users autonomy in their learning, making learning visible and the freedom to fail and retry without consequences. Most importantly, it makes the learning experience fun and enjoyable, motivating the users to improve continuously and practise frequently, which is crucial to maintain language proficiency. For instance, a gamified platform known as Coursemology is well received by students and has been proven in be effective.
As such, in our application, we plan to couple learning with fun games so that users are motivated to consistently enhance their Chinese proficiency in a fun way. We also plan to target users at all levels of proficiency, spanning from the beginners to the experts, by including a wide spectrum of difficulty levels.
We are adding a new game type called "Sliding Game". Similar to the 15-puzzle sliding puzzle, there will a grid with an empty cell in it. The player must swap the empty cell with neighbouring cells to form 3 valid phrases either vertically or horizontally. Given that there is an empty cell, there will be an incomplete phrase with 1 missing word, which can act as a diversion to confuse the player.
We are also changing the swapping game to allow valid phrases to be formed either horizontally or vertically (previously only horizontally). This is to standardize the gameplay for all 3 games (swapping, sliding, tetris), by allowing both horizontal and vertical arrangement of valid phrases.
The scores for swapping and sliding games will be changed to account for the time taken and number of moves (swaps/slides) performed (number of moves not used previously). Since valid phrases are allowed either vertically or horizontally, players will have to consider whether horizontal or vertical arrangement for that game instance is better to minimize the number of swaps.
We are also adding more customizability to the game. In particular, we now allow users to create custom levels with their custom set of phrases. This gives the users more control over the phrases they want to practise on. Users can also import phrases from other levels and merge them to their custom levels.
For the tetris game, to improve the difficulty of the game, the upcoming tiles will not include the entire phrase. Instead, a certain number of phrases (currently set at 2) will be generated to populate the upcoming tiles randomly, so that the phrases to be tested are only revealed fully after a few turns.
For the tetris game, we also implemented an innovative way to get rid of tiles that the user did not manage to solve successfully and has become indestrutible due to their positionings. A phrase containing the words on these tiles will be added to the upcoming tiles pool. Upon successfully forming the phrase and destroying it, all tiles with words that are contained in the destroyed tiles will also be instantly destroyed, and tiles above all the destroyed tiles will be shifted down accordingly. Additional tiles destroyed in this manner will not give additional scores.
We can achieve 60 FPS on both the iPhone and the iPad, as the game animation and physics are simple enough.
- Number of tiles in the grid are currently set at 12x8, which is unlikely to go much higher due to screen size limit
- There will only be 1 falling tile at a time
- There will at most be 12x8 tiles being animated (shifted down or destroyed)
- At most 16 cells in the Swapping game type, where at any instance only 2 cells will be shifted.
- At most 16 cells in the sliding game type, where at any instance only 1 cell will be shifted.
Also, we target our game to run with less than 150 MB of memory, and with more than 70% CPU usage for only 10% of the time.
Models should have their own unit tests ensuring that their defaults variables are set correctly, as well as the correct implementation of any computed properties and functions. Tests will be devised for the normal case as well as the failure case. There will be tests for every branching in the code, such as if and guard statements.
ViewModels are disjoint from the View, and hence can be easily testable. The non-private methods should be tested to ensure correct behaviour, and a mock that fulfills certain delegate protocols should be implemented to ensure that the delegates are called as they should have.
For ViewControllers, they are highly reliant on black box testing due to the large variability of GUI testing. Since views do not contain any significant logic, if the Models and ViewModels are tested well, views can then be tested with black box testing.
We use the Mac's in-built Instruments
application to measure the FPS. We conducted a few typical game runs and recorded the mean and range of the FPS. Also, we can measure the performance via CPU and memory usage, ensuring that they are within reasonable level.
We have also performed some basic stress test on the tetris game such as rapidly tapping the grid to shift the falling tile left, right, or down, and rapid swapping of the tiles in the swapping game. Multiple simutaneous tap gestures are also tested.
For the Tetris game, we will stack up multiple full columns then destroy the bottom most row to stress test the shifting down of multiple full columns of tiles.
For swapping and sliding grid game, we have stress test it by spamming touch gestures right onto the view, ensuring that the game area does not break.
Other stress tests may include having the application to handle a huge database of Chinese vocabulary (~20000 words), and ensure that the performance of the app is still acceptable.
We protected the master branch on our github repository (https://github.com/jasmine-team/Jasmine) and set up the Travis CI to require every test cases to pass before the pull request can be merged to the master branch. We also repeat the black-box testing of related UI components for every major build or UI changes.
As our project implements Travis CI service, our master
branch build is passing with ~60% code coverage. Do note that some code paths such as private functions, singletons and game center related code are largely untestable. Do note that if the project has yet to implement some of the tests described in the Appendix (including the black box testing), they are replaced by black-box tests.
The performance tests showed that we can achieve our targeted FPS of 60 for our Tetris and Swapping games, even with animations.
The stress tests helped us find certain bugs that can only happen in extreme conditions such as moving tiles in the Swapping game that hasn't landed yet. Now that we have fixed them, these tests showed that the UI remains responsive and we can maintain the desired performance at the current level of animation.
Our application adopts the MVVM (Model - View - ViewModel) pattern. This is the pattern adopted in most iOS applications; even Apple’s “MVC” framework is closer to MVVM than actual MVC. The model contains the main objects of the app, the ViewModel contains game logic and the View (consisting of ViewControllers and the UIViews) displays the representation of the game on the screen.
We choose the MVVM design over the MVC design to avoid having a disproportionately large VC. A separation between the view and model also allows the models to be changed without the need to change the view. The separation also improves readability, maintainability, scalability, as well as the testability of the view model.
We have 3 game modes (Swapping, Tetris, Sliding) and 2 game types (Cheng Yu and Ci Hui). In our games, we aim to achieve a clean partition between the game types and the respective game modes, so that different game modes can be added for each game type in the future. Hence, there is a ViewController corresponding to each game type and a ViewModel corresponding to each (game mode, game type) pair that extends the base ViewModel/ViewController for the game type to provide specific functionalities for the different game mode. Each ViewController handles how each game type is displayed on the screen, and returning the user inputs to the ViewModel. ViewModel then governs the rules of the game, and handles the logic of the game, and then tells the ViewController what to be displayed on screen.
While each ViewController holds the corresponding ViewModel object, each ViewModel also holds communication pathways to the VC in the form of delegates. The delegates are separated based on their functionality; for example, TimeUpdateDelegate
provides the ViewModel means to update the time stored in the ViewController, and ScoreUpdateDelegate
lets the VM update the score in VC. This ensures that a 2-way connection between VM and VC is established. We implemented ViewModelProtocol
s to be adopted by ViewModels, and the delegates to be adopted by ViewControllers. The protocol acts as a facade and makes the communication between the ViewModels and ViewControllers clear and explicit. This reduces the constant need for communication between the VC and the VM developers and eliminates any potential misunderstanding of the interface between the VM and VC. Furthermore, if we were to create a new game mode, all we need is to create a ViewModel that corresponds to the game mode, that conforms to the specific game type's view model protocol. This allows for simple and clear extensions to our games.
We also created a CountDownTimer
class. ViewModels that use a timer in their games will have this CountDownTimer
inside the VMs. There was an interesting discussion about this timer; we had three options to choose from:
- Use a
TimedViewModel
class, and ViewModels that need a timer subclass from that class - Use a
TimedViewModel
protocol, and ViewModels that need a timer implements that protocol - The current approach (an independent class that is contained in the ViewModels)
After discussion, we decided to stick to our current implementation. Using a class doesn't sound right in a protocol-oriented framework; besides, every class can only subclass once, and if more features are to be implemented, there will be more classes like this that the VMs need to implement. We tried to use a protocol with default implementations inside protocol extensions ("abstract class"). However, that does not work quite well as stored properties cannot be set or declared in an extension. As a result, we need to set each of the stored properties in each of the main view model classes, which clusters up the view model classes, especially when other functionalities such as sound are introduced. Furthermore, certain initializations, such as setting timeRemaining
to totalTimeAllowed
would have to be done in the init of the main view model classes even though such logic should be handled by the timer class. Hence the current implementation is chosen to avoid these problems and provide a cleaner solution.
For our Tetris game, we debated whether to let the view model control the falling tile and constantly update the view controller to redisplay the falling tile on its new position, or to simply let the view controller control the falling tile and notify the view model when the falling tile has landed. We decided to go with the latter as it allows the view controller greater control over how the falling tile is animated, such as whether it should fall discretely, row by row, or continously. Also, the view models (for three game mode) can focus on implementing the game mechanics without worrying about how the tiles fall in each game mode, as this is a UI component. What is only required from the view model is for the view controller to ask the view model whether the tile can be moved to a particular coordinate or to land, which maintains the encapsulation, and avoids processing of the view model's data.
Another interesting design issue is on code duplication. Apparently, there are some code duplication, as assessed by Codebeat. In particular, there are some duplication between the Grid ViewModels (SlidingCiHui
, SwappingCiHui
, SlidingChengYu
, SwappingChengYu
). Indeed, since we decided to split the GridViewModel
into SlidingViewModel
and SwappingViewModel
, there are some duplication between the CiHui
view models and ChengYu
view models. If Swift supports multiple inheritance in some cases, this could have been solved more gracefully.
However, since Swift doesn't support multiple inheritance, we had the choice of either putting the code in the BaseViewModel
, or splitting and duplicating them into the corresponding ViewModels. In the end, we decided to combine some into the Base VM, while still duplicating some of them. Indeed, there is a choice here between writing it in one place and making it DRY, or decoupling them and increasing extensibility. For example, we decided to decouple the scoring system in both games (checkGameWon
) since the grid games should be flexible in how the game decides how to score, which tiles to highlight, and how to win. (What if the entire grid needs to be in a certain position?)
For the models, we are using Realm
, for these reasons:
- Realm handles migrations much more cleanly than iOS's CoreData
- There is no need to deal with Objective-C type limitations with Realm as compared to CoreData
- Queries can be chained easily without performance issues
- Realm makes the classes more testable through ability to set Realm instances through dependency injection.
- Realm performance is much better, and does not load all data immediately into memory.
- There is an app that allows you to edit Realm databases like an excel sheet.
We are also using a shared grid model called TextGrid
, as an abstraction over grids containing Strings. These grids map Coordinate
to String
, and is used in our swapping, tetris, and sliding game modes.
Some libraries that we use in our CocoaPods are:
Library | Purpose |
---|---|
Realm | a mobile database and ORM |
SwiftLint | a linter for Swift to enforce coding style |
Chameleon | a color framework |
SnapKit | programatically fit constraints onto views |
SwiftDate | clean and easy date operations |
Some other third-party things that we use include:
Tool | Purpose |
---|---|
Travis CI | to ensure compilation works and tests pass |
CodeCov | for code coverage report |
Synx | a tool that automatically matches the file directory structure with the grouping structure in XCode |
CodeBeat | a tool that assesses the codebase and reports lower-quality code |
One of the tradeoffs of this design (overall architecture) is that there will be more ViewModels to work with, which if not carefully managed, may result in more code and duplication. This is slightly mitigated due to CodeBeat coverage which looks out for duplication in code.
In addition, since many kinds of ViewModels are expected to work with ViewControllers, the design of ViewControllers is crucial. If not designed well where methods are extensible, additional ViewControllers may need to be introduced, defeating the point of this pattern.
Lastly, MVVM may be quite a new architecture pattern for people to get used to, and thus may introduce code where responsibility is not cleanly separated. Ensuring that the team understands the pattern is thus essential.
The models consist of Phrase
, the most important data component. It encapsulates one or more words in many different languages, such as Chinese and English (for the time being). A single data type keeps data persisting easy to manage since there's only one "table" to be copied. For the internal variables for Realm, a string is used to represent the Pin Yin with a space as the delimiter. An array of String would be more appropriate as it'd allow the extensibility of having spaces in a word, and other classes using the class will not need to be aware of the magical space delimiter. However, Realm does not support storing an array of primitives, hence the String is used for the internal representation for Realm, and a wrapper that turns the String into [String] is created for public access from other classes.
Phrases
encapsulates the realm results object returned through a query, and hides away Realm implementation details behind functions exposed to ViewModels.
Initially, we thought that Phrases
just have to store the list of phrases tested in a level. However, we realized there could be situations where the user form a phrase that is valid and in the database but not in the list of phrases tested for that level. Hence to avoid false negative Phrases
would contain the entire database of phrases to check against, and hence the need for Realm to store the list lazily as the database would be enormous to be stored in memory.
Another model is the Level
, which is a representation of a game level, and contains necessary information to create such a level to play a game. All Level
objects can be found via Levels
, which is retrievable through Storage
singleton.
Every ViewModel would hold GameData
which represents data necessary, such as Phrases for a game, all of which is created by a GameDataManager
. This keeps the Model layer clean and simple, separated from the other responsibilities. Since all models (Phrase
and PlayerData
) are direct Realm objects, performance co-relates to Realm's amazing performance.
The grid data across all games are represented by the TextGrid
model. We represent the content to be displayed on each tile as String instead of Character as this would allow multiple characters to be displayed on the tile, which would be needed to display Pin Yin. Coordinate
is a struct containing 2 integers representing the column and row number on the grid. However, we decided to represent the TextGrid
with a 2D array of String?
, which is [[String?]]
, as it provides a more natural way in our methods in TextGrid
such as getting a row. It also retains efficient O(1) time complexity for read/write to the grid.
The UICollectionView
will be implemented with every row on the grid belonging to 1 section. Hence IndexPath.row
will correspond to the column number while IndexPath.section
will correspond to the row number on the grid. As this is unintuitive and hard to visualise, we choose to use the Coordinate struct instead of IndexPath to represent the tile location on the grid for our data model. A mapping between IndexPath
and Coordinate
has been done via an extension in IndexPath
.
Refer to above class diagram under "overview" for an overview of our module structure.
Models are generally all Realm objects, with their governing objects (e.g. Phrases
for Phrase
realm object) exposed for use to view models. View models will query for information through these governing objects as needed.
All GameData object are created throughGameManager
which holds the spawned level object. GameData acts as a token to be passed throughout stages of the game play. Information can be stored and obtained as needed through this generic GameData object.
Due to the efficiency of Realm only recalling data in which it is needed, certain choices between converting realm collections to native swift arrays are considered between performance and convenience.
Realm is never exposed to the View Models and thus dependency on realm is largely contained within the models layer. Should a change in storage library be needed, view model will not need a large refactoring to do so.
As illustrated above, our game component consists of the game view controller and its accompanying view model.
The MVVM pattern, the 2-way communication between VM-VC through a pre-agreed set of view model protocols and view controller delegates (in that VC contains VM protocol and VM contains a delegate protocol that goes to VC), and the VM class that implements each (game type, game mode) pair allows the project to be decoupled enough. Each class has its own responsibility and this allows us to have a better division of work, since the use of protocols allows people working on the VC does not need to wait until the people working on the VM to be done, and vice versa. This also allows the testability of view controllers and the view models by means of dependency injection.
For the ViewModels, there is a BaseViewModelProtocol
that is adopted by all game engines. This base VM adopts the GameDescriptorProtocol
(describes the game). We included TimeDescriptorProtocol
(describes the timing of the game) and ScoreDescriptorProtocol
(describes the score of the game) that the implementing game view model can adopt only when they need it, which also explains why we subdivide it in this manner. Each of the above protocols comes with a corresponding XDescriptorDelegate
that the implementing view controller can implement for the view model to call, and the view controller to listen for data updates. This interactions reduces copuling to a single direction between the view controller and view model.
Subsequently, we have TetrisViewModelProtocol
that extends the BaseViewModelProtocol
directly, while the protocols SlidingGameViewModelProtocol
and SwappingGameViewModelProtocol
extend the GridViewModelProtocol
that extends the Base protocol, since there are so many similarities between the two games. For each of these game type specific protocols (except Tetris), 2 classes will extend them, corresponding to the 2 game modes.
Likewise, similar principles are applied to other view components, such as LevelSelector
, LevelDesigner
, and PhraseExplorer
views.
In the view controller, we aimed to achieve having high flexibility for the view components to be reused. Specifically, a generic SquareGridViewController
and GameStatisticsViewController
is used to display a m-by-n square grid, and in-game statistics such as time remaining and game score. This keeps the main view controllers such as SwappingGameViewController
, SwipingGameViewController
and TetrisGameViewController
small as most of the operations are done inside the generic controllers itself. There is no need to recode la collection view controller for SwappingGameVC
, SlidingGameVC
and TetrisGameVC
respectively.
To allow greater customisability to the SquareGridViewController
, we have establised an hierarical system for this view controller. Along with a trivial display of tiles, DraggableSquareGridVC
, DiscreteFallingSquareGridVC
and SelectableSquareGridViewController
are built upon with the ability of dragging and dropping of tiles, the falling of tiles, and the selectability of tiles.
These implementation keeps each class/file size small while at the same time provides sophiscated functionalities to each game type. For example, Tetris game can now rely on DiscreteFallingSquareGridVC
to display its main falling grid, and a simple SquareGridViewController
to display the upcoming tiles. By designing the game view controller to be higly modularised with various embedded view controllers, we have improved code reuse and reduces the coupling between the view controllers and the native UICollectionView
.
In fact, GameOverViewController
is able to reuse SelectableSquareGridViewController
without having to recode the entire collection view to display a simple list of phrases. Also, the weekly streak in the ProfileViewController
is made up of SquareGridViewController
as well.
With this regard, we have applied this similar pricinple to other reusable view controllers such as GameStatisticsViewController
and SimpleStartGameViewController
.
We understand that traditional view of inheritance cannot be apply to view controllers, simply because there exist a one to one mapping and view attachment between view controllers and the layout in the storyboard. However, there are avenues where there are overlaps in code (e.g. SwappingGridGameViewController
and SwipingGameViewController
), so we created a BaseGameViewController
class between the three game VCs, and have the children classes to pass their shared view element up to the parent class.
Similar implementation has been done between the grid games through BaseGridGameViewController
, and all view controllers in general via JasmineViewController
. In particular, having a general JasmineViewController
allows for a central control over the themeing of the views, such as controlling the colour the navigation bar, and the status bar colour.
Although this reduces code duplication between various view controllers and improve sharing of properties between VCs (e.g. themeing), one significant trade-off is that it increases coupling between the hierarchy, simply because both the parent VC class and the children VC class has to know whether the actual view in storyboard is attached.
The architecture of the code is something that we are proud of. A very good separability between the components are achieved. This made our development very conformtable.
As a tradeoff, though, our development speed is quite slow. We are quite particular about minor things. As time progresses, though, we are slowly becoming more lenient, trading off code quality with development speed.
Also, since the design keeps on changing, the tests need to be changed as well. Some parts of the application are harder to test, making it more time-consuming in writing tests. In particular, the Grid games are still quite hard to test until now, since the movement of tiles is limited. It's as if we are creating an artificial intelligence to play the game.
Setting up tools such as CODEBEAT to assess the quality of our code has been really helpful, as automatic code review allows us to catch ugly codes and improve our code quality early on. As a result of our efforts, we have achieve grade B on the code quality rating provided by CODEBEAT.
One might wonder why we achieved a B and not an A. We looked into it and most of them are caused by code duplication. There is always a tradeoff between code duplication and extensibility though, as described above in the "Designs" section. Do note that CODEBEAT standards are also quite strict, (e.g. having only 6 properties would be deducted for having too many instance properties)
Previously, we felt that our work is progressing very slowly; at this pace, we will not be able to complete the app in 6 weeks. The main problem we found out is in our collaboration flow.
Originally, we wanted all four people to review every pull request so that everyone knows exactly what is going to be pushed to master
. This does not work so well, though, since not everyone is always active. Some trivial pull requests, such as the addition of a library, can require hours or even days to be merged.
Also, we realized that we are too picky in our guidelines. Sometimes we ask the author of a PR to just change variable names or even comments. This resulted in the PR taking even longer time to be merged, because we ask the author of the PR to make these trivial changes again and again. We also spent so much time debating trivial things such as line length (maximum number of characters in a length).
To solve these problems, we decided on these things:
- A PR needs to be reviewed by only one reviewer before being merged
- Anyone can push trivial changes to anyone's branches, as long as the author of the branch is being notified
- We will try to meet every Monday 4pm-6pm and Friday 12pm-2pm. We will require at least 2 people to be present in every timeslot. This allows face-to-face pair programming which allows us to make faster and better decisions.
We have adopted these changes since last Friday and our work has been much more productive ever since.
Also, our general architecture has slight changes compared to the design outlined in the first document. We think this is natural; designs are bound to changed if they are found unsuitable for the app.
Sometimes we have a hard time deciding how certain things should be implemented (some of which are mentioned above). To save time, we decided to simply go with the one that is simplest to implement, and change it accordingly should the need arise.
We think that Prof. Ben is right by saying that there was an issue with team dynamics. A way to solve this would probably be choosing someone to be the team leader and let him/her be the team arbiter.
- Sometimes the sound fails blackbox testing (likely due to inconsistency of timers).
- There might be instances where sound effects continue to play after a game gesture is spammed.
- Audio levels do not persist between app restarts.
- Clearing and drag-gesture multi-selecting of all checkmarkable views (
PhraseExplorerVC
,LevelImporterVC
) are not supported at this point.
No user format is exposed.
Local requirements need prebundled.realm
file to seed the information in the database.
In the directory, we have a Python3 script that scraps phrases from an API and stores it in a Realm file. To generate the phrases, we have a set of instructions written in the README.md
file, reproduced here:
- First copy the secrets file:
cd scripts && cp secrets.example.py secrets.py
- Set up environment variables by changing the file
secrets.py
- Run
python3 api.py
to populate the CSV file - Download Realm browser and import CSV to turn it into Realm file
- Replace the Realm file in
Jasmine/Common/prebundled.realm
with the produced Realm file
We also have a Python script that generates the metadata necessary for game center and creates and achievements and leaderboards from the Level.csv
file. Instructions are reproduced here:
- Find iTMS, usually in
/Applications/Xcode.app/Contents/Applications/Application Loader.app/Contents/itms
- Run
iTMSTransporter -m upload -u <user> -p <password> -f <path>/Jasmine/scripts
to upload - Run
iTMSTransporter -m lookupMetadata -u <user> -p <password> -apple_id 1223383989 -destination <path>/Jasmine/scripts
to download metadata
This is an encapsulation of a sequence of words that contain meaning, thus having various properties that are labeled with information.
Properties:
english: String
. The english representation of this wordchinese: String
. The chinese representation of this wordchineseMeaning: String
. The chinese meaning of this wordenglishMeaning: String
. The english meaning of this word
This is a collection of phrases for usage. Access is fast due to consideration of performance and number of phrases in the final database.
This is an encapsulation of a grid of Strings, mapping a Coordinate to a String.
Properties and methods:
numRows: Int
. The number of rows in the gridnumColumns: Int
. The number of columns in the gridcount: Int
. Total number of cells in the gridtexts: Set<String>
. Set of all texts in the gridinit(fromInitialGrid: [[String?]])
,init(fromInitialRow: [String?])
,init(fromInitialColumn: [String?])
. Initializer functions given a grid. Creates a 2D grid, a one-row grid and a one-column grid respectively.init(numRows: Int, numColumns: Int)
. Creates a 2D grid initialized with all nils.subscript(coordinate: Coordinate) -> String?
. Gets and sets the element in the given coordinate, just like an array.swap(_: Coordinate, _: Coordinate)
. Swaps two elements in the specified coordinates.hasText(at: Coordinate) -> Bool
. Returns true if and only if the text in the coordinate is not nil.removeTexts(at: Set<Coordinate>)
. Sets the text in the given coordinates to nil.getTexts(at: [Coordinate]) -> [String]?
. Gets the texts of the given coordinates. Returns nil if any of the texts is nil.getConcatenatedTexts(at: [Coordinate], separatedBy: String) -> String?
. Gets the texts of the given coordinates, concatenated optionally by a given separator. Returs nil if any of the texts is nil.isInBounds(coordinate: Coordinate) -> Bool
. Returns true if and only if the coordinate is in bounds of the text grid. In other words, the coordinate's row is in 0 to the last row of the text grid, and the coordinate's column is in 0 to the last column of the text grid.getCoordinates(containing: Set<String>) -> Set<Coordinate>
. Returns the set of coordinates in the text grid containing the given texts.
This grid needs to have a constant number of cells in every row, and a constant number of cells in every column. In other words, there cannot be rows with different number of cells.
This is a simple countdown timer.
Properties and methods:
totalTimeAllowed: TimeInterval
. The total time in this timer.timeRemaining: TimeInterval
. The time remaining.timerListener: ((TimerStatus) -> Void)
. A listener that allows functions to be executed at certain events of the timer, depending on theTimerStatus
.init(totalTimeAllowed: TimeInterval)
. Initializes the timer with the given total time allowed.startTimer(timerInterval: TimeInterval)
. Starts the timer with the given timer interval. The timer interval tells how fast the timer ticks. The time will still be decremented according to the number of seconds, though.stopTimer()
stops the timer.
For this timer, the time remaining and the timer interval must be numbers greater than 0.
This defines a coordinate system of discrete integers. In this coordinate system, the top-left tile is indexed as Coordinate(row: 0, col: 0)
. The row number and the column number gets bigger as one goes down or right, respectively.
Properties and methods:
row: Int
. Returns the row number of the given coordinate.col: Int
. Returns the column number of the given coordinate.init(row: Int, col: Int)
. Initializes a coordinate, given the row and column number.nextRow: Coordinate
. Returns the coordinate of one row down.nextCol: Coordinate
. Returns the coordinate of one column right.prevRow: Coordinate
. Returns the coordinate of one row up.prevCol: Coordinate
. Returns the coordinate of one column left.toIndexPath: IndexPath
. Gets the index path representation of the given coordinate.isWithin(numRows: Int, numCols: Int) -> Bool
. Returns true if and only if the coordinate given is in the bounds of the number of rows and columns.
For this coordinate system, the row and column number must both be non-negative.
ViewModels conforming to this protocol has a score component in it.
Properties and methods:
currentScore: Int
. The current score of the game.scoreDelegate: ScoreUpdateDelegate?
. A delegate that the viewmodel notifies when the score is updated.
ViewModels conforming to this protocol has a time component in it.
Properties and methods:
timeRemaining: TimeInterval
. The time remaining of this game.totalTimeAllowed: TimeInterval
. The total time allowed in this game.timeDelegate: TimeUpdateDelegate?
. A delegate that the viewmodel notifies when the time is updated.
This protocol contains properties that describe a game. This protocol also conforms to ScoreDescriptorProtocol
and TimeDescriptorProtocol
Properties and methods:
levelName: String
. The name of the level. Used to display the title of the game.gameInstruction: String
. Brief instructions for the game.phraseTested: Set<Phrase>
. A set of phrases tested in this game.gameStatus: GameStatus
. Current status of the game. Can benotStarted
,inProgress
,endedWithWon
,endedWithLost
.gameStatusDelegate: GameStatusUpdateDelegate?
. A delegate that the viewmodel notifies when the game status is updated.
This protocol sets the shared functionalities across all game view models. This protocol conforms to GameDescriptorProtocol
.
Properties and methods:
gridData: TextGrid
. The grid contained in the game.startGame()
. Starts the game.
This protocol defines a grid game (the Swapping and Sliding games). This protocol conforms to BaseViewModelProtocol
.
Properties and methods:
highlightedCoordinates: Set<Coordinate>
. The coordinates in the grid that are highlighted.highlightedDelegate: HighlightedUpdateDelegate?
. A delegate that the viewmodel notifies when the set of highlighted coordinates are updated.
- Top bar
- in most of the views, the top bar should be green
- All except home screen should have white status bar font and images.
- the title should be placed at the middle
- Ensure that the name of the view is sensible.
- Back button
- If there is a "BACK" button, should be placed at the top left
- should dismiss current view and return to the previous screen on click
- Auto-rotation
- Only available in portrait mode.
- Background music
- Should be played while in the app.
- Stops playing when the background music is muted in settings or when the app is minimised.
- Test drag and drop
- drag corners (top-left, top-right, bottom-left, bottom-right) to neighbouring cells, should swap positions
- drag center to neighbour, should swap positions
- drag random cell to other cells, should swap positions
- drag cell to out of bounds, should go back to original positions
- Test forming a phrase
- correct answer should provide feedback (highlighted cells)
- wrong answer should not provide feedback
- Test solving game before time out
- game should end with presentation of victory screen
- game should be in correct solved positions
- Test not solving game before time out
- game should end with presentation of lose screen
- game should not be solved
- On loaded
- tile should appear at random place (on the top row) and starts falling in a discrete manner.
- Test swipe left and right while tile is falling OR tapping left and right of the falling tile
- tile should move left and right once per swipe
- tile should remain within bounds of screen
- tile should not move if the target direction is obstructed by another tile
- Test swipe down OR tapping underneath the falling tile.
- Tile should fall to the bottom and locked to the screen, subsequent landing operation should occur
- Test placing the tile
- tile should stop moving when it reaches the bottom of the grid, or if there is a tile beneath it.
- tile should not move once locked into place
- Test forming a phrase
- tile should trigger deletion if it forms a valid answer
- tile should not trigger deletion if it is not a valid answer
- if triggered deletion, tiles on top should fall down to fill remaining space
- When deletion is triggered
- deleted tiles should explode with a pop sound
- other tiles that has a hole underneath should fall subsequently.
- chain effect should take place if there are matching tiles that formed from the falling action.
- tiles with characters that matches with the exploded tile should explode as well.
- Test losing game conditions
- game should end when reaching top of screen with tiles
- game should end when not enough answers were cleared within time limit
- losing game screen should appear
- Test victory game conditions
- game should end when finishing game within time limit and with required combinations
- victory game screen should appear
- Test upcoming tile
- the view should show a list of upcoming tiles in order.
- the latest upcoming tile should be visually distinct from the rest (highlighted)
- Test drag and drop
- tiles can only be dragged horizontally/vertically
- when a tile is moved to an empty space, the tile should be placed to the empty space
- when a tile is about to be moved to a non-empty space, the tile should be blocked from proceeding further
- Test forming a phrase
- correct answer should provide feedback (highlighted)
- wrong answer should not provide feedback
- Test solving game before time out
- game should end with presentation of victory screen
- game should be in correct solved positions
- Test not solving game before time out
- game should end with presentation of lose screen
- game should not be solved
- Test score updated:
- score label (below) should grow and shrink with gold font.
- score label should reflect the correct score.
- Test timer updated:
- time label should update on its own.
- grow and shrink animation with red font is only applicable if timer reaches remaining 10 seconds.
- Test press "BACK" button:
- ends the game and dismisses the screen.
- Check:
- that the title matches the title in the level that was selected.
- Verify view elements:
- Check that final score is correct.
- Check that "CONGRATULATIONS" or "GAME OVER" is shown as appropriate to the outcome of the game.
- Check that phrases displayed are the ones played in the game.
- Test press "BACK" button:
- dismisses both the game over screen and the previous game screen.
- Test press the phrases
- should open the Phrase view.
- Test Jasmine flowers
- the flowers should follow the gravity, i.e. the accelerometer of the phone.
- Test buttons
- the Play button should bring the levels screen
- the Help button should bring the help screen
- the About button should bring the about screen
- the Settings button should bring the settings page
- the Levels button at the bottom should bring the levels screen
- the Profile button at the button should bring the user's profile screen
- Test view
- it should show all levels, divided by default and custom levels
- every level should show the level name, game mode, game type, and a menu icon
- Test info icon
- clicking the info icon should bring up a menu
- the menu should consist of "View Phrases" and "Clone" for default levels
- the menu should consist of "View Phrases", "Clone", "Edit", and "Delete" for custom levels
- Test pressing delete
- Only available to custom levels, that level should disappear.
- Test pressing edit
- Opens level designer with the specified custom level preloaded.
- Test pressing clone
- Opens level designer with the specified custom level preloaded.
- Should be able to save it as a new level.
- Test view
- it should show all phrases: their Chinese characters (hanzi) and their English meaning
- there should be a search bar on top
- Test search bar
- clicking on it should give first responder (show keyboard)
- searching should show the phrases that match the hanzi, pinyin, or the English meaning, as it is typed
- clicking Cancel should resign first responder (remove keyboard)
- Test transition to Phrase view
- if on view-only mode: clicking on a phrase should segue to phrase view directly
- if on selection mode: long press on a phrase should segue to phrase view
Note: This view is ususally opened from level designer view.
- Verify after opening:
- "SAVE" and "BACK" button are visible
- Global list of phrases should appear, depending if it is Ci Hui or Cheng Yu (specified by level designer).
- Test selecting an entry:
- Check mark should appear
- Test view
- it should show the Chinese characters, pinyin, and English meaning of the phrase
- it should show a sound icon
- Test sound
- clicking on the sound icon should play the pronounciation of the phrase clearly
- while the sound is being played, the game music volume should be reduced, and returned after the TTS ends.
- Test opening the view
- Audio knob should reflect actual audio level.
- Test dragging background and sound fx track bar:
- Audio's loudness should be adjusted according to the specified level.
- Level should persist when the screen reopens.
- Test tapping sign in/out button:
- Should provide instructions of logging in to game center.
- Test opening "SELECT PRHASES" when ci hui or cheng yu is selected
- list of phrases in phrases explorer should match ci hui or cheng yu
- should display the global list of phrases
- Upon completion, the number of selected phrases should be reflected on the button.
- Subsequent re-entry to the view should still have the same phrases selected.
- Test opening "IMPORT PHRASES FROM LEVELS" when ci hui or cheng yu is selected
- list of levels in level importer should contain only ci hui or cheng yu levels as appropriate.
- Upon completion, the number of selected phrases should be reflected on the button.
- Entering the phrases explorer should see what are the selected view.
- Switching between cihui and cheng yu
- The list of phrases should switch according to cihui and cheng yu.
- If a selection has been made earlier, it should be preserved after switching back.
- Test pressing "BACK"
- A warning dialog should show, where:
- Cancel should dismiss it
- Pressing "Yes" should dismiss the view
- Test pressing "SAVE"
- Without a name, and all other fields filled
- Saves with name: "Untitled Level x"
- With a duplicated name, and all other fields filled
- Shows an overwrite dialog box, where:
- Pressing cancel dismisses it
- Pressing "Overwrite" replaces existing level with the specified name.
- With a unique name, but no phrases selected
- Shows an error dialog box prompting to select a phrase or more.
- Verify on open
- View should display the list of levels, switchable between default and custom levels
- Selecting a level
- Presents a checkmark
- Press done
- dismisses and pass back the level selected to previous view
- Press back
- dismisses the view
- Test
GameData
score
should be zero at startphrases
must be results from realm query- Test
GameManager
createGame
creates GameDatacreateGame
sets propertiesdifficulty
andphrases
correctlysaveGame
saves the level result into realm- Test
Coordinate
Hashable
is implemented correctlyEquatable
is implemented correctly - different objects must not equate to each other- Test
Phrases
contains
returns true if and only if the phrase is in the phrases listfirst
returns the first Phrase that satisfies the given Chinese correctly- Test
Levels
original
should return original levelscustom
should return custom levelsadding
should add to custom levelsdelete
should delete from custom levelsresetAll
should delete all custom levels- Test realm integration
phrases
loads the phrases properly- Test
TextGrid
init
loads the grid properlynumRows
andnumColumns
detect the number of grid rows and columns properlycoordinateDictionary
gives the Coordinate dictionary representation of the gridinit(numRows:numCols:)
creates an empty grid with the given rows and columns- The subscript get and set is returns and sets the grid cell correctly
swap
swaps the two tiles located in the given coordinateshasText
returns true if and only if the tile in the coordinate is not nilremoveTexts
sets the given texts in the given Coordinates to nilgetTexts
returns the texts in the given coordinatesgetConcatenatedTexts
returns the texts in the given coordinates and concatenates them
- Test
Array+Extensions
[T] == [T?]
,[T?] == [T]
,[T] != [T?]
, and[T?] != [T]
compares the arrays element-by-element- Test
CGRect+Arithmetic
init(center, size)
andcenter
: the conversion between center and origin should be correct.init(minX:maxX:minY:maxY:)
should create the rectangle given the bounds of the coordinates.- Test
IndexPath+Coordinate
init(coordinate)
andtoCoordinate
: converts betweenIndexPath
andCoordinate
structs correctly.- Test
Matrix+Equatable
- 2D arrays should be equal if and only if their elements are equal pairwise
- Test
MutableCollection+Extensions
shuffle
randomizes the array uniforml- Test
Sequences+Extensions
hasNoDuplicate
returns true if and only if the sequence has no duplicate (all are unique)isAllTrue
returns true if and only if all the elements in the array satisfy the given predicate- Test
UIApplication
firstLaunch
is true if and only if the application is launched on the first time- Test
UISwipeGestureRecognizerDirection+Directions
up
,down
,left
, andright
should be mapped tonorthwards
,southwards
,westwards
,eastwards
- Test
CountDownTimer
init
initializes the timer with the correct time allowedstartTimer
starts the timer, ticks the timer according to the timer interval supplied, and sends timer updates to thetimerListener
functionstopTimer
stops the ticking of the timer and sends timer stop update to thetimerListener
function- Test
LevelError
duplicateLevelName
error should show "Duplicate level name: (levelName)"noPhraseSelected
error should show "No phrase selected"- Test
RandomGenerator
next
should return the next random element from the remaining iterator, and should reset if the iterator is emptynext(count:)
should callnext
a few times according to the the number supplied in the count- Test
Random
integer(from:toInclusive:)
should return an integer uniformly random fromfrom
totoInclusive
integer(from:toExclusive:)
should return an integer uniformly random fromfrom
totoExclusive - 1
double(from:toInclusive:)
should return a double uniformly random fromfrom
totoInclusive
- Test
PhraseViewModel
- Test
PhraseExplorerViewModel
- Test
GridViewModel
- On
init
, all properties should be set properly: gridData
contains the tiles, shuffled. The size should be the number of rows and columns givencurrentScore
should be 0highlightedCoordinates
should be emptyphrasesTested
should be emptytimer
should be the countdown timer initialized according to the total time giventimeRemaining
andtotalTimeAllowed
should read fromtimer
gameStatus
should start with.notStarted
gameData
should be thegameData
supplied ininit
levelName
should equal to the name from thegameData
gameType
should be the game type supplied ininit
startGame
should start the timerlineIsCorrect(_:)
should check the coordinates given in the grid, concatenates them, and checks with the database. This is done in the default case. For special cases (that is read fromgameType
, such as.ciHui
), it is handled accordingly. ForciHui
the game should split the coordinates into half and check if the first half matches the text in second half (hanzi - pinyin or pinyin - hanzi).- Test
BaseSlidingViewModel
slideTile
should slide the tile from the start coordinate to the end coordinate, if allowed. It should only be allowed if the distance between them is 1, and both are valid coordinatescanTileSlide(start:)
returns all the coordinates in which the tile can be slided into, which are the adjacent tiles that are still inside the gridcheckCorrectTiles
should highlight the tiles if the rows/columns form a valid line according tolineIsCorrect
. If 3 rows or columns are done, the game should be declared won.- Test
ChengYuSlidingViewModel
andCiHuiSlidingViewModel
init
should set the total time given, the phrases taken from thegameData
and the number of rows givenslideTile
should runcheckCorrectTiles
if the tile slide is successful- Test
BaseSwappingViewModel
swapTiles
should swap the tile from the start coordinate to the end coordinate and it should always allow thatcheckCorrectTiles
should highlight the tiles if the rows/columns form a valid line according tolineIsCorrect
. If 3 rows or columns are done, the game should be declared won.- Test
ChengYuSwappingViewModel
andCiHuiSwappingViewModel
init
should set the total time given, the phrases taken from thegameData
and the number of rows givenswapTiles
should runcheckCorrectTiles
if the tile slide is successful
-
Test
SquareGridViewController
-
allTiles
: Should return all the tiles that are currently stored in this view. -
allDisplayedTiles
: Should return all the tiles that are currently displayed in this view. -
allCoordinates
: Should return all the coordinates that are used for this collection view. -
segueWith(initialData, numRows, numCols, space)
: correct number of rows and columns is displayed in the square tiles grid along with the initial data being displayed. If space is supplied, the grid should be presented with space between each tile. -
update(collectionData)
andreload(cellsAt ...)
: by calling update, the stored data in this view controller is updated, but not displayed. Upon calling reload should display the data. -
getCoordinate(at position/from tile)
: gets the correct cell coordinate at the appropriate view coordinates. If invalid position/tile is supplied, should return nil. -
getCell(at coordinate)
: gets the correct viewcell at the specifed coordinate. If invalid coordinate is supplied, should return nil. -
getFrame(at coordinate)
: gets the cell frame at the specified coordinate. If invalid coordinate is supplied, should return nil. -
getCenter(from coordinate)
: gets the cell center point at the specified coordinate. If invalid coordinate is supplied, should return nil. -
addTileOntoCollectionView(...)
: Should attach a tile view onto the collection view controller. -
bringTileToFront(...)
: If tile is found in the collection view, should bring this view to the front. -
Test
DraggableSquareGridViewController
: -
detachedTiles
: Should be tested with other methods. When any detached method is called, the supplied view should be included here. When any reattach method is called, the supplied view should be removed from here. -
allTiles/DisplayedTiles
: Verify that it includesdetachedTiles
. -
canRepositionDetachedTileToCoord/Position
: Verify that when any move/snap/reattach method is called, this method is being called. -
detach
methods: Should only cause the tiles to be removed from the cell (if needed) and placed right on the collection view. -
move/snapDetachedTile(...)
: by calling detached, move and then snap, grid tiles on the screen is able to move freely in the view controller at the specified position, and then gets snapped into the grid. -
Should not be reattached to a particular cell in the grid.
-
If detach method is not called, the view should not be movable with this method.
-
If a callback is specified, should be called when the action is done.
-
reattachDetachedTile(...)
: the specified tile should be removed from the grid collection view and place inside the view cell at specified coordinate. -
If detach method is not called, should result in no-op.
-
Test
DiscreteFallableSquareGridViewController
: -
onFallingTileRepositioned/Landed
: verify that these two methods are called when the tile repositions/ completes landing. -
fallingTile
,fallingTileCoord
,hasFallingTile
: if there is a falling tile on display, should return that tile, along with the associated information about it's coordinate, and whether is it present. -
startFallingTiles(interval)
: when called, the falling tile (if any) in the view should start falling with the specified interval. -
pauseFallingTile()
: when called, verify that all tiles should stop falling. -
setFallingTile(...)
: verify that it creates and places a falling tile at the specified coordinate. if there is a falling tile that has yet to land, should throw an assertion error. -
shiftFallingTile(...)
: the falling tile (if any) should shift one step to the specifed direction described in theDirection
enum. -
landFallingTile(...)
: lands the falling tile by attaching to the grid in the specified coordinat, and vacate thefallingTile
property (set to nil). -
Test
GameStatisticsViewController
: -
currentScore
should be reflected on the view when initialised and updated. -
timeLeft
should be reflected on the view when initialised and updated. -
Test
SwappingGameViewController
: -
segueWith(viewModel)
: assuming that a mock view model associated with this view controller is instantiated, this view should display the appropriate chinese characters on the grid with the time remaining and starting score. -
onBackPressed
: this view should be dismissed. Warning dialog may be added in the future. -
onTilesDragged
: should correctly emulate the drag and drop action of the tiles. Starts game when necessary. Specifically: -
if the tile lands on another tile, both tiles should switch position (integration test with VM).
-
if the tile lands on nowhere else, and itself, returns to original position.
-
Test
update
andredisplay
viaGridGameViewControllerDelegate
: This is tested together with VM via integration testing, where a command by the VM to update and subsequently redisplay the data should be reflected on the view controller's screen. For example, grid data, score and time remaining. -
Test
TetrisGameViewController
: -
segueWith(viewModel)
: assuming that a mock view model associated with this view controller is instantiated, this view should display the appropriate chinese characters on the tetris grid with upcoming tiles, time remaining and starting score. -
tapHandler(recogniser)
: When user interacted, if there is a falling tile and not obstructed, shifts to the left or the right. This also causes the game to start if not done so. -
onBackPressed
: this view should be dismissed. Warning dialog may be added in the future. -
Test
update
andredisplay
viaTetrisGameViewControllerDelegate
: This is tested together with VM via integration testing, where a command by the VM to update and subsequently redisplay the data should be reflected on the view controller's screen. For example, tetris grid data, score and time remaining. -
Test
SlidingGameViewController
: -
segueWith(viewModel)
: assuming that a mock view model associated with this view controller is instantiated, this view should display the appropriate characters on the grid, with one or more empty cells (no tiles). -
onBackPressed
: this view should be dismissed. Warning dialog may be added in the future. -
onTileDragged
: should emulate the process of tiles dragging and dropping to the cells without any tiles. Note that it should be locked to only horizontal and vertical directions, and should not proceed further if it is blocked by other tiles. -
Test
update
andredisplay
viaSlidingGameViewControllerDelegate
: This is tested together with VM via integration testing, where a command by the VM to update and subsequently redisplay the data should be reflected on the view controller's screen. For example, grid data, score and time remaining. -
Test
animateTiles([(coordToExplode, coordToShift)])
: Should animate the tiles being exploded and tiles being shifted to the specified coordinates in sequential order governed by the array. -
Across the three view controllers:
-
Test end game state: When the view model declares a win/lost to the game via delegate callback, the view controller should display it as appropriate.